Join: merge A2g's checker, view, watchman, builder, teller and enrolment readers (wip/registry-a2g aafd90b)

No conflicts.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 14:48:00 +01:00
55 changed files with 2356 additions and 321 deletions
@@ -17,6 +17,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-builder/internal/build"
)
@@ -264,7 +265,11 @@ func main() {
a, err := build.DecodeReAsk(v)
return err == nil && !a.Answered.IsZero()
})
if err := stdio.Serve("", Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now)); err != nil {
// builder_first_views and builder_first_view_again: where the builder's first views of the indexer's and the assigner's
// states stand, and asking one again by hand (ADR 0615, DLV-M1).
tools := append(Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now),
views.StatusTools(delivery.BuilderModule, "builder_first_views", "builder_first_view_again", stdio.State(views.StatusState), time.Now)...)
if err := stdio.Serve("", tools); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] %v\n", err)
os.Exit(1)
}
@@ -27,6 +27,7 @@ import (
runner "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-delivery/sdk/firstview/firstviewtest"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -270,6 +271,13 @@ func startBedWith(t *testing.T, closed bool) *bed {
b.candidates = mk(delivery.CandidatesBucket, delivery.CandidatesHistory)
b.source = mk(delivery.SourceBucket, delivery.SourceHistory)
mk(delivery.ManifestsBucket, delivery.ManifestsHistory)
// The indexer and the assigner as the owners of the states the builder follows: their read tools and views families
// (ADR 0615, DLV-M1); n1 and n2 each have their durable consumer.
readers := []string{"n1_" + delivery.BuilderModule, "n2_" + delivery.BuilderModule}
for _, st := range []string{modulesState, manifestsState, candidatesState} {
firstviewtest.Serve(t, ctx, js, ctl, delivery.IndexerModule, st, readers...)
}
firstviewtest.Serve(t, ctx, js, ctl, delivery.AssignerModule, delivery.PlacementState, readers...)
b.builds = mk(delivery.BuilderBuildsBucket, delivery.BuilderBuildsHistory)
b.instances = mk(instancesBucket, 1)
// The builder's module log, as the runtime raises it (ADR 0297): its stream on the log's subjects.
@@ -791,7 +799,12 @@ func TestTheBuilderAsksTheBuildSeatsAndCallsNoNode(t *testing.T) {
}
defer sub.Close()
var calls atomic.Int64
s, err := sub.Subscribe("mesh.mod.*.call.>", func(*nats.Msg) { calls.Add(1) })
// A build module's tool, never the owners' read tools the builder's first views ask (DLV-M1).
s, err := sub.Subscribe("mesh.mod.*.call.>", func(m *nats.Msg) {
if strings.HasPrefix(m.Subject, "mesh.mod.mesh-build-") {
calls.Add(1)
}
})
if err != nil {
t.Fatal(err)
}
@@ -25,6 +25,9 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/firstview"
"mesh-delivery-builder/internal/build"
)
@@ -61,16 +64,23 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err != nil {
return err
}
modules, err := process.EssentialBucket(ctx, js, "builder", delivery.ModulesBucket, "know any fingerprint to build")
// The indexer's modules, manifests and candidates and the assigner's placement are their owners' states, read by the
// first view of their read tools and then their views families, never their buckets (ADR 0615 §1, DLV-M1). An owner that
// is down never holds the start: each is read on a goroutine once its first view is answered, and until then the
// unanswered first view is the builder's finding.
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
if err != nil {
return err
}
manifests, err := process.EssentialBucket(ctx, js, "builder", delivery.ManifestsBucket, "read what a module builds")
if err != nil {
return err
}
// The candidates' fingerprints are built too (issue 1684): a builder that cannot read them builds the trunk's.
candidates := process.OptionalBucket(ctx, js, delivery.CandidatesBucket, "no candidate's fingerprint is built until the builder starts again; the trunk's are", logger.Printf)
defer calls.Close()
reader := &firstview.Reader{Module: delivery.BuilderModule, Call: calls.Call, Logf: logger.Printf}
findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf)
reader.Findings = findings.KV
modules := reader.Follow(delivery.IndexerModule, modulesState)
manifests := reader.Follow(delivery.IndexerModule, manifestsState)
// The candidates' fingerprints are built too (issue 1684).
candidates := reader.Follow(delivery.IndexerModule, candidatesState)
placement := reader.Follow(delivery.AssignerModule, delivery.PlacementState)
bound := defaultStepBound
if v := strings.TrimSpace(getenv("MESH_BUILDER_STEP_BOUND")); v != "" {
if bound, err = time.ParseDuration(v); err != nil {
@@ -103,12 +113,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
heard := buildrun.HearEnds(ctx, asks, asks, buildModules, logger.Printf)
conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") })
// The builder asks the controller, as its own account, whether the artifact store still holds a done key's blobs
// (the seat verb held, novox/hq issue 1937) before it leaves the key done.
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
if err != nil {
return err
}
defer calls.Close()
// (the seat verb held, novox/hq issue 1937) before it leaves the key done, on its calls connection.
b := &build.Builder{
Claimer: build.Claimer{Store: kvStore{builds}, By: cred.Node + "." + cred.Module},
Asks: buildrun.Asker{Publisher: asks, Waiter: buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}, Bound: bound},
@@ -165,10 +170,21 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// placement is settled first, so the toolchains settle through the gate already knowing it.
gate := buildrun.NewToolchainGate([]string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, tools.Put, logger.Printf)
watch.put = gate.Toolchain
placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, b.NodesChanged, logger.Printf)
if err != nil {
return err
}
// Until the placement's first view is answered the gate passes every report on, as before the placement is settled.
placementErrs := make(chan error, 1)
go func() {
select {
case <-ctx.Done():
return
case <-placement.Follower().Ready():
}
pe, err := buildrun.FollowPlacement(ctx, reader, gate, b.NodesChanged, logger.Printf)
if err != nil {
placementErrs <- err
return
}
placementErrs <- <-pe
}()
if err := watch.start(); err != nil {
return err
}
@@ -202,6 +218,68 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
defer cc.Stop()
}
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "builder_first_views reads nothing and builder_first_view_again is not heard", logger.Printf))
// The modules, once their first views and the manifests' are answered: what a module builds is read from the manifests.
go func() { errs <- buildFromModules(ctx, b, modules, manifests, candidates) }()
// A person asks a key's build again with builder_ask_again, which writes the reasks state (issue 1945): watched, as
// every other input. The builder asks again by itself without it.
// A state not on the bus yet at this start is watched once it appears (issue 1790).
process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, kv, e, logger.Printf) }
if rw, err := settle(ctx, kv, eachReAsk); err != nil {
process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again")
} else {
go func() { errs <- follow(ctx, rw, eachReAsk) }()
}
})
// A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the
// builder alone may read the stream, so it answers there.
process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachRead := func(e jetstream.KeyValueEntry) { queueRead(ctx, heldOnTheBus{js}, kv, e, logger.Printf) }
if qw, err := settle(ctx, kv, eachRead); err != nil {
process.NotWatched(logger.Printf, build.QueuesBucket, err, "builder_seat_queue is not answered until the builder starts again")
} else {
go func() { errs <- follow(ctx, qw, eachRead) }()
}
})
logger.Printf("started on %s", cred.Node)
select {
case <-ctx.Done():
b.Wait()
return nil
case err := <-errs:
return err
case err := <-placementErrs:
return err
case err := <-watch.errs:
return err
}
}
// The builder's own states of its first views (views.StatusState) and of its findings, where the condition keeper raises a
// first view unanswered past its bound (ADR 0615 §6).
var (
firstViewsBucket = delivery.BuilderModule + "_" + views.StatusState
findingsBucket = delivery.BuilderModule + "_findings"
)
// The indexer's states the builder follows (delivery.ModulesBucket, ManifestsBucket, CandidatesBucket).
const (
modulesState = "modules"
manifestsState = "manifests"
candidatesState = "candidates"
)
// buildFromModules builds from the indexer's modules and candidates once the first views of the modules, the manifests and
// the candidates are answered (never at the start: an indexer that is down holds no other part of it), and follows them.
func buildFromModules(ctx context.Context, b *build.Builder, modules, manifests, candidates *firstview.Picture) error {
for _, p := range []*firstview.Picture{modules, manifests, candidates} {
select {
case <-ctx.Done():
return nil
case <-p.Follower().Ready():
}
}
each := func(e jetstream.KeyValueEntry) {
if opOf(e) {
b.Module(ctx, e.Key(), e.Value())
@@ -225,48 +303,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
b.ForgetCandidate(e.Key())
}
}
if candidates != nil {
cw, err := settle(ctx, candidates, eachCandidate)
if err != nil {
process.NotWatched(logger.Printf, delivery.CandidatesBucket, err, "no candidate's fingerprint is built until the builder starts again")
} else {
go func() { errs <- follow(ctx, cw, eachCandidate) }()
}
}
// A person asks a key's build again with builder_ask_again, which writes the reasks state (issue 1945): watched, as
// every other input. The builder asks again by itself without it.
// A state not on the bus yet at this start is watched once it appears (issue 1790).
process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, kv, e, logger.Printf) }
if rw, err := settle(ctx, kv, eachReAsk); err != nil {
process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again")
} else {
go func() { errs <- follow(ctx, rw, eachReAsk) }()
}
})
// A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the
// builder alone may read the stream, so it answers there.
process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachRead := func(e jetstream.KeyValueEntry) { queueRead(ctx, heldOnTheBus{js}, kv, e, logger.Printf) }
if qw, err := settle(ctx, kv, eachRead); err != nil {
process.NotWatched(logger.Printf, build.QueuesBucket, err, "builder_seat_queue is not answered until the builder starts again")
} else {
go func() { errs <- follow(ctx, qw, eachRead) }()
}
})
logger.Printf("started on %s", cred.Node)
go func() { errs <- follow(ctx, w, each) }()
select {
case <-ctx.Done():
b.Wait()
return nil
case err := <-errs:
return err
case err := <-placementErrs:
return err
case err := <-watch.errs:
cw, err := settle(ctx, candidates, eachCandidate)
if err != nil {
return err
}
go func() { _ = follow(ctx, cw, eachCandidate) }()
return follow(ctx, w, each)
}
// sourceHeads are the trunk heads of each revision of the gate's source.main the watch delivered, the newest
+4
View File
@@ -25,3 +25,7 @@ require (
)
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ./buildrun
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
+14 -5
View File
@@ -93,13 +93,13 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
if m.Module != "mesh-delivery-builder" {
t.Errorf("module = %q", m.Module)
}
if want := []state{{Name: "builds", History: 5}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
if want := []state{{Name: "builds", History: 5}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
t.Errorf("state = %+v, want %+v", m.State, want)
}
// 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and
// holds no capacity; it puts asks on the three build seats and reads their steps (ADR 0525 §1, §4, §5).
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-build-node.toolchains",
"mesh-delivery-assigner.placement", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules"}; !reflect.DeepEqual(sorted(m.Reads), want) {
"mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) {
t.Errorf("reads = %v, want %v", m.Reads, want)
}
if want := []string{"node-build-go", "node-build-image", "node-build-node"}; !reflect.DeepEqual(sorted(m.Uses), want) {
@@ -108,13 +108,22 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
// 1937/AC3: the builder asks the controller whether the artifact store still holds a done key's blobs (held), and no
// other tool; it holds no capacity.
// 1955/L7 (ADR 0571 §2): the seat mesh-artifact-sweep's held, the controller's `held` is no longer asked (issue 1955).
if want := []string{"seat:mesh-artifact-sweep.held"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
// DLV-M1 (ADR 0615 §1): the indexer's modules, manifests and candidates and the assigner's placement are asked by their
// read tools and followed by their views families, never read.
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)
}
}
if want := []string{"mesh-delivery-assigner.assigner_read_placement", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules", "seat:mesh-artifact-sweep.held"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
t.Errorf("invokes = %v, config = %v, want %v and no config", m.Invokes, m.Config, want)
}
// The builder is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it
// starts; and by the seat mesh-artifact-sweep's artifacts-gone, a blob the sweep found gone (1937/AC4, 1955/L7), the
// controller's no longer (1126/B9, ADR 0590).
if want := []string{"mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
if want := []string{"mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported",
"mesh-delivery-assigner.views.placement", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" {
@@ -124,7 +133,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
t.Errorf("secrets-owner = %q", m.SecretsOwner)
}
// 1945: the builder's tools (builder_seat_queue: note 62), served by its tools bundle, and the event it says when it asks an ask again.
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_seat_queue", "builder_stuck"}; !reflect.DeepEqual(sorted(m.Tools), want) {
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_first_view_again", "builder_first_views", "builder_seat_queue", "builder_stuck"}; !reflect.DeepEqual(sorted(m.Tools), want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
if want := []string{"build.asked-again"}; !reflect.DeepEqual(m.Emits, want) {
+17 -9
View File
@@ -8,7 +8,9 @@
"builder_build_again",
"builder_builds_since",
"builder_seat_queue",
"builder_stuck"
"builder_stuck",
"builder_first_views",
"builder_first_view_again"
],
"emits": [
"build.asked-again"
@@ -16,17 +18,27 @@
"consumes": [
"mesh-artifact-sweep.artifacts-gone",
"mesh-build-go.toolchain.reported",
"mesh-build-node.toolchain.reported"
"mesh-build-node.toolchain.reported",
"mesh-delivery-assigner.views.placement",
"mesh-delivery-indexer.views.candidates",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-indexer.views.modules"
],
"invokes": [
"seat:mesh-artifact-sweep.held"
"seat:mesh-artifact-sweep.held",
"mesh-delivery-assigner.assigner_read_placement",
"mesh-delivery-indexer.indexer_read_candidates",
"mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules"
],
"state": [
{ "name": "builds", "history": 5 },
{ "name": "hearing", "history": 1 },
{ "name": "instances", "history": 1 },
{ "name": "queues", "history": 1 },
{ "name": "reasks", "history": 1 }
{ "name": "reasks", "history": 1 },
{ "name": "first-views", "history": 1 },
{ "name": "findings", "history": 1 }
],
"logs": [
{ "name": "build-log", "max-age-days": 30 }
@@ -37,11 +49,7 @@
"mesh-build-image.steps",
"mesh-build-node.steps",
"mesh-build-node.toolchains",
"mesh-delivery-assigner.placement",
"mesh-delivery-gate.source",
"mesh-delivery-indexer.candidates",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules"
"mesh-delivery-gate.source"
],
"uses": [
"node-build-go",
@@ -13,6 +13,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-checker/internal/check"
)
@@ -326,6 +327,9 @@ func main() {
return err
}}
tools := append(append(append(Tools(r), TryTools(trying)...), SeatTryTools(trying)...), RecoveryTools(r, written)...)
// checker_first_views and checker_first_view_again: where the checker's first views of the indexer's and the assigner's
// states stand, and asking one again by hand (ADR 0615, DLV-M2).
tools = append(tools, views.StatusTools(delivery.CheckerModule, "checker_first_views", "checker_first_view_again", stdio.State(views.StatusState), time.Now)...)
if err := stdio.Serve("", logged(tools)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err)
os.Exit(1)
@@ -27,6 +27,7 @@ import (
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/firstview/firstviewtest"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -440,6 +441,12 @@ func startBed(t *testing.T) *bed {
}
b.buckets[name] = kv
}
// The indexer and the assigner as the owners of the states the checker follows: their read tools and views families
// (ADR 0615, DLV-M2).
for _, st := range []string{candidatesState, modulesState, manifestsState, delivery.PackagesState, delivery.IndexerPredefinedState, seatDefinitionsState} {
firstviewtest.Serve(t, ctx, js, ctl, delivery.IndexerModule, st, "n1_"+delivery.CheckerModule)
}
firstviewtest.Serve(t, ctx, js, ctl, delivery.AssignerModule, delivery.PlacementState, "n1_"+delivery.CheckerModule)
// 1929/AC1: the indexer's value of the repository's module, with no TypeScript manifest, so the Go build module alone
// judges the repository (an unknown repository is judged by both, issue 1887 A12).
known, err := delivery.EncodeModule(delivery.Module{Repository: repo, Commit: trunk, Path: "modules/m", None2: "the fingerprint is computed with its chain"})
@@ -1071,7 +1078,7 @@ func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1
}
if said.Module != "mesh-delivery-checker" || said.Node != "n1" || !said.Keeps("owed") || !said.Readable("owed") ||
!said.Readable("previews") || !said.Readable("verdicts") || !said.Readable("kept") || !said.Readable("tries") || said.Readable("hearing") ||
!said.Keeps("asks") || said.Readable("asks") || len(said.States) != 7 {
t.Fatalf("the checker said %+v, want its seven states, asks and hearing its own, with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said)
!said.Keeps("asks") || said.Readable("asks") || !said.Keeps("first-views") || said.Readable("first-views") || !said.Keeps("findings") || len(said.States) != 9 {
t.Fatalf("the checker said %+v, want its nine states, asks, hearing and first-views its own (DLV-M2), with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said)
}
}
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -25,6 +26,8 @@ import (
// judgeDeps are the parts the judge of a check is made of.
type judgeDeps struct {
asks check.Asks
renames check.Renames
seatDefs check.SeatDefinitions
tools *buildrun.ToolchainSet
trunks *check.Trunks
kept check.KV
@@ -51,14 +54,10 @@ type judgeDeps struct {
}
// keptPredefined reads the indexer's key predefined.list (delivery.PredefinedKept), bound once its state appears.
type keptPredefined struct{ b *process.AwaitedBucket }
type keptPredefined struct{ kv jetstream.KeyValue }
func (k keptPredefined) PredefinedList(ctx context.Context) (json.RawMessage, error) {
kv := k.b.KV()
if kv == nil {
return nil, process.ErrNotYet
}
e, err := kv.Get(ctx, delivery.PredefinedListKey)
e, err := k.kv.Get(ctx, delivery.PredefinedListKey)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
@@ -72,6 +71,68 @@ func (k keptPredefined) PredefinedList(ctx context.Context) (json.RawMessage, er
return kept.List, nil
}
// keptSeatDefinitions reads a pull request's entries of the indexer's seat definitions from its first view: each whose
// `unparsed` says why its head's definition does not parse (the entry's shape is the indexer's read tool's answer).
type keptSeatDefinitions struct {
keys func(ctx context.Context) ([]string, error)
get func(ctx context.Context, key string) ([]byte, error)
}
// seatDefinitionsOf reads the state's picture (a key gone between the keys and its get is none).
func seatDefinitionsOf(kv jetstream.KeyValue) keptSeatDefinitions {
return keptSeatDefinitions{keys: func(ctx context.Context) ([]string, error) {
keys, err := kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}, get: func(ctx context.Context, key string) ([]byte, error) {
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return e.Value(), nil
}}
}
func (k keptSeatDefinitions) Unparsed(ctx context.Context, repository string, pull int) ([]check.UnparsedSeat, error) {
suffix := ".pull." + delivery.EscapeName(repository) + "." + strconv.Itoa(pull)
keys, err := k.keys(ctx)
if err != nil {
return nil, err
}
sort.Strings(keys)
var out []check.UnparsedSeat
for _, key := range keys {
if !strings.HasSuffix(key, suffix) {
continue
}
raw, err := k.get(ctx, key)
if err != nil {
return nil, err
}
if raw == nil {
continue
}
var v struct {
Seat string `json:"seat"`
Module string `json:"module"`
Directory string `json:"directory"`
Unparsed string `json:"unparsed"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, fmt.Errorf("%s is no seat definition entry: %w", key, err)
}
if v.Unparsed != "" {
out = append(out, check.UnparsedSeat{Seat: v.Seat, Module: v.Module, Directory: v.Directory, Why: v.Unparsed})
}
}
return out, nil
}
// nodeNames reads every node's name from the node-engine's have, one key `<node>.node` per node with a record (novox/hq
// ADR 0567 §12), at each ask: a node enrolled since is named in the next check.
type nodeNames struct{ kv jetstream.KeyValue }
@@ -159,7 +220,7 @@ func newJudge(d judgeDeps) check.Judge {
nodes = d.nodes
}
ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools,
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Logf: d.logf}
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, Logf: d.logf}
if d.nodes != nil && d.builds != nil && d.repos != nil {
ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf}
}
@@ -4,14 +4,18 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-checker/internal/check"
)
type memManifests map[string][]byte
@@ -142,6 +146,20 @@ func TestThePublishedVersionsAreTheIndexersPackagesState(t *testing.T) {
if got := p.LatestPackages(); len(got) != 1 || got[sdk] == "" {
t.Errorf("after a delete: %v", got)
}
t.Run("a pull request's unparsed seat definitions are its entries of the indexer's seat-definitions", func(t *testing.T) {
state := map[string]string{
"web-api.trunk": `{"seat":"web-api","source":"trunk","digest":"d"}`,
"web-api.pull." + delivery.EscapeName("novox/web") + ".7": `{"seat":"web-api","source":"pull","module":"web-contracts","directory":"go","unparsed":"seat.json: bad"}`,
"mail-api.pull." + delivery.EscapeName("novox/web") + ".7": `{"seat":"mail-api","source":"pull","digest":"e"}`,
"web-api.pull." + delivery.EscapeName("novox/web") + ".17": `{"seat":"web-api","source":"pull","unparsed":"another pull request's"}`,
}
k := keptSeatDefinitions{keys: func(context.Context) ([]string, error) { return slices.Collect(maps.Keys(state)), nil },
get: func(_ context.Context, key string) ([]byte, error) { return []byte(state[key]), nil }}
got, err := k.Unparsed(context.Background(), "novox/web", 7)
if want := []check.UnparsedSeat{{Seat: "web-api", Module: "web-contracts", Directory: "go", Why: "seat.json: bad"}}; err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("unparsed of novox/web#7 = %+v, %v; want %+v", got, err, want)
}
})
}
// A manifest whose config value falls back to a predefined key (ADR 0348 §7) is read, not logged as unreadable for the
@@ -24,6 +24,9 @@ import (
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/firstview"
"mesh-delivery-checker/internal/check"
)
@@ -61,6 +64,23 @@ func durationEnv(getenv func(string) string, name string, def time.Duration) (ti
return d, nil
}
// The checker's own states of its first views (views.StatusState) and of its findings, where the condition keeper raises a
// first view unanswered past its bound (ADR 0615 §6); and the indexer's states it follows (delivery.CandidatesBucket,
// ModulesBucket, ManifestsBucket).
var (
firstViewsBucket = delivery.CheckerModule + "_" + views.StatusState
findingsBucket = delivery.CheckerModule + "_findings"
)
const (
candidatesState = "candidates"
// seatDefinitionsState is the indexer's state of the seat definitions (design 26), keyed `<seat>.trunk` and
// `<seat>.pull.<escaped repository>.<number>`.
seatDefinitionsState = "seat-definitions"
modulesState = "modules"
manifestsState = "manifests"
)
// run is the program: it returns when ctx ends or the bus cannot be used.
// keptStates is every state the checker keeps and which others may read (ADR 0495 §2-§3): owed by the forge module,
// which writes mesh/check from it (ADR 0521), and by the forge bridge; previews by the indexer; verdicts by the gate; tries by the delivery view.
@@ -77,6 +97,10 @@ var keptStates = []announce.State{
// The tries are read by the delivery view, which tells a try's asks from a candidate's by the asks each names (build_usage).
{Name: "tries", Readable: true},
{Name: "verdicts", Readable: true},
// Where its first views of the indexer's and the assigner's states stand (read by checker_first_views alone), and its
// findings of one unanswered past its bound, which the condition keeper observes as it does every reader's (ADR 0615 §6).
{Name: views.StatusState},
{Name: "findings"},
}
func run(ctx context.Context, getenv func(string) string, logOut io.Writer) error {
@@ -90,11 +114,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
}
defer conn.Close()
// Its own states, the candidates it checks, and the modules and manifests that say which build modules judge a repository:
// a check without one of them would be wrong or unwritten, so each stops the start, naming its grant (issue 1945).
// Its own states: a check without one of them would be unwritten, so each stops the start, naming its grant (issue 1945).
buckets := map[string]jetstream.KeyValue{}
for _, name := range []string{delivery.CheckerVerdictsBucket, delivery.CheckerKeptBucket, delivery.CheckerOwedBucket, delivery.PreviewsBucket,
delivery.CandidatesBucket, delivery.ModulesBucket, delivery.ManifestsBucket, check.TriesBucket} {
check.TriesBucket} {
if buckets[name], err = process.EssentialBucket(ctx, js, "checker", name, "judge or keep a check"); err != nil {
return err
}
@@ -111,6 +134,21 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err := caller.Keeps(keptStates...); err != nil {
logger.Printf("what the checker keeps could not be said on the bus, so readers of owed, previews and verdicts are not granted until it is: %v", err)
}
// The candidates it checks, the modules and manifests that say which build modules judge a repository, the published
// packages, the predefined keys and the placement are their owners' states (the indexer's, the assigner's), read by the
// first view of their read tools and then their views families, never their buckets (ADR 0615 §1, ADR 0596, DLV-M2).
// An owner that is down never holds the start: the checks start once every first view is answered, and until then the
// unanswered first view is the checker's finding.
reader := &firstview.Reader{Module: delivery.CheckerModule, Call: caller.Call, Logf: logger.Printf}
reader.Findings = process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf).KV
candidates := reader.Follow(delivery.IndexerModule, candidatesState)
modules := reader.Follow(delivery.IndexerModule, modulesState)
manifests := reader.Follow(delivery.IndexerModule, manifestsState)
packages := reader.Follow(delivery.IndexerModule, delivery.PackagesState)
predefined := reader.Follow(delivery.IndexerModule, delivery.IndexerPredefinedState)
placement := reader.Follow(delivery.AssignerModule, delivery.PlacementState)
// The seventh: the indexer's seat definitions, so a pull request whose head's definition does not parse fails.
seatDefs := reader.Follow(delivery.IndexerModule, seatDefinitionsState)
stepBound, err := durationEnv(getenv, "MESH_CHECKER_STEP_BOUND", check.DefaultStepBound)
if err != nil {
@@ -183,7 +221,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
verdicts := &manifestVerdicts{}
compositions := check.EngineCompositions{
Asker: engineAsks{call: caller.CallOn},
Placed: placedNodes{kv: func(at context.Context) (jetstream.KeyValue, error) { return js.KeyValue(at, delivery.PlacementBucket) }}.Placed,
Placed: placedNodes{kv: func(context.Context) (jetstream.KeyValue, error) { return placement, nil }}.Placed,
Bound: answerBound,
Sandbox: seatSandbox{asker: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}},
Verdicts: verdicts.all,
@@ -196,23 +234,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
logger.Printf("%s of %s was written and the part that reads it could not be told: %v", event, key, err)
}
}
repos := &repoModules{manifests: kvStore{buckets[delivery.ManifestsBucket]}, logf: logger.Printf, verdicts: verdicts}
repos := &repoModules{manifests: kvStore{manifests}, logf: logger.Printf, verdicts: verdicts}
compositions.Composer = func() string { return repos.CommitOf(check.NodeEngineModule) }
compositions.Manifests = repos
// The indexer's packages state (ADR 0593 §2): followed once the bus has it, so a Go check builds every mesh package at
// its published version; until then a check builds each at the version its go.mod names.
// The indexer's packages state (ADR 0593 §2), so a Go check builds every mesh package at its published version: read
// once the first views are answered, before any check starts (afterFirstViews).
published := &publishedPackages{}
process.AwaitBucket(ctx, js, conn, delivery.PackagesBucket, "a Go check builds each mesh package at the version its go.mod names",
logger.Printf).Ready(func(kv jetstream.KeyValue) {
w, err := settle(ctx, kv, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
if err != nil {
logger.Printf("%s cannot be watched, so a Go check builds each mesh package at the version its go.mod names: %v", delivery.PackagesBucket, err)
return
}
go func() {
_ = follow(ctx, w, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
}()
})
ch.Runs = repos // the node-engine a check reads beside at its running commit (ADR 0548 §6)
// A group's tagged versions (design 51 *Tagged versions*): tag-wanted on the checker's stream asks, and the package
// registry seats' published heard on the checker's durable consumer of the shared events stream. Where that consumer is
@@ -239,9 +266,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]},
repos: repos, published: published, builds: builds, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf,
announce: announce, compositions: compositions, tags: tags,
nodes: openNodeNames(ctx, js, logger.Printf),
predefined: keptPredefined{process.AwaitBucket(ctx, js, conn, delivery.IndexerModule+"_"+delivery.IndexerPredefinedState,
"a change's manifests are judged with no predefined keys, each fallback as its value declares it", logger.Printf)},
nodes: openNodeNames(ctx, js, logger.Printf),
predefined: keptPredefined{predefined},
renames: seatRenames{call: caller.Call},
seatDefs: seatDefinitionsOf(seatDefs),
})
ch.Announce = announce
tryer.Judge = ch.Judge
@@ -252,10 +280,21 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// placement is settled first, so the toolchains settle through the gate already knowing it.
gate := buildrun.NewToolchainGate([]string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, tools.Put, logger.Printf)
watch.put = gate.Toolchain
placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, ch.NodesChanged, logger.Printf)
if err != nil {
return err
}
// Until the placement's first view is answered the gate passes every report on, as before the placement is settled.
placementErrs := make(chan error, 1)
go func() {
select {
case <-ctx.Done():
return
case <-placement.Follower().Ready():
}
pe, err := buildrun.FollowPlacement(ctx, reader, gate, ch.NodesChanged, logger.Printf)
if err != nil {
placementErrs <- err
return
}
placementErrs <- <-pe
}()
if err := watch.start(); err != nil {
return err
}
@@ -338,65 +377,89 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return nil
}
lazy.ensure()
// The modules say which repository holds a TypeScript module, so the Node build module judges it too.
mw, err := settle(ctx, buckets[delivery.ModulesBucket], func(e jetstream.KeyValueEntry) { repos.Put(ctx, e.Key(), e.Value(), !opOf(e)) })
if err != nil {
return err
}
go func() {
errs <- follow(ctx, mw, func(e jetstream.KeyValueEntry) {
before := repos.CommitOf(check.NodeEngineModule)
repos.Put(ctx, e.Key(), e.Value(), !opOf(e))
if after := repos.CommitOf(check.NodeEngineModule); after != before {
ch.TrunkMoved(ctx) // the node-engine the mesh runs moved: a verdict that read it beside is carried (ADR 0548 §6)
}
})
}()
each := func(e jetstream.KeyValueEntry) {
if opOf(e) {
ch.Candidate(ctx, e.Key(), e.Value())
} else {
ch.Forget(e.Key())
afterFirstViews := func() error {
// The modules say which repository holds a TypeScript module, so the Node build module judges it too.
pw, err := settle(ctx, packages, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
if err != nil {
return err
}
}
w, err := settle(ctx, buckets[delivery.CandidatesBucket], each)
if err != nil {
return err
}
// A key left running for heads no candidate holds is closed; the candidates are all known now.
if err := ch.Sweep(ctx); err != nil {
return err
}
// The tries: a queued one starts, one left running is taken over, an ended one past its keeping is removed.
tw, err := settle(ctx, buckets[check.TriesBucket], func(jetstream.KeyValueEntry) {})
if err != nil {
return err
}
tryer.Reconsider(ctx)
go func() {
errs <- follow(ctx, tw, func(e jetstream.KeyValueEntry) {
go func() {
errs <- follow(ctx, pw, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
}()
mw, err := settle(ctx, modules, func(e jetstream.KeyValueEntry) { repos.Put(ctx, e.Key(), e.Value(), !opOf(e)) })
if err != nil {
return err
}
go func() {
errs <- follow(ctx, mw, func(e jetstream.KeyValueEntry) {
before := repos.CommitOf(check.NodeEngineModule)
repos.Put(ctx, e.Key(), e.Value(), !opOf(e))
if after := repos.CommitOf(check.NodeEngineModule); after != before {
ch.TrunkMoved(ctx) // the node-engine the mesh runs moved: a verdict that read it beside is carried (ADR 0548 §6)
}
})
}()
each := func(e jetstream.KeyValueEntry) {
if opOf(e) {
tryer.Put(ctx, e.Key(), e.Value())
}
})
}()
// The checker's own verdicts, updates only, in one watch: one checker_ask_again wrote asks its check again
// (Checker.VerdictWritten); a deleted key of heads a candidate holds is judged again (Checker.VerdictDeleted, issue 1733).
vw, err := buckets[delivery.CheckerVerdictsBucket].WatchAll(ctx, jetstream.UpdatesOnly())
if err != nil {
return fmt.Errorf("the checker's verdicts cannot be watched, so no check can be asked again by hand and a deleted verdict would never be written again: %w", err)
}
go func() {
errs <- follow(ctx, vw, func(e jetstream.KeyValueEntry) {
if opOf(e) {
ch.VerdictWritten(ctx, e.Key(), e.Value())
ch.Candidate(ctx, e.Key(), e.Value())
} else {
ch.VerdictDeleted(ctx, e.Key())
ch.Forget(e.Key())
}
})
}
w, err := settle(ctx, candidates, each)
if err != nil {
return err
}
// A key left running for heads no candidate holds is closed; the candidates are all known now.
if err := ch.Sweep(ctx); err != nil {
return err
}
// The tries: a queued one starts, one left running is taken over, an ended one past its keeping is removed.
tw, err := settle(ctx, buckets[check.TriesBucket], func(jetstream.KeyValueEntry) {})
if err != nil {
return err
}
tryer.Reconsider(ctx)
go func() {
errs <- follow(ctx, tw, func(e jetstream.KeyValueEntry) {
if opOf(e) {
tryer.Put(ctx, e.Key(), e.Value())
}
})
}()
// The checker's own verdicts, updates only, in one watch: one checker_ask_again wrote asks its check again
// (Checker.VerdictWritten); a deleted key of heads a candidate holds is judged again (Checker.VerdictDeleted, issue 1733).
vw, err := buckets[delivery.CheckerVerdictsBucket].WatchAll(ctx, jetstream.UpdatesOnly())
if err != nil {
return fmt.Errorf("the checker's verdicts cannot be watched, so no check can be asked again by hand and a deleted verdict would never be written again: %w", err)
}
go func() {
errs <- follow(ctx, vw, func(e jetstream.KeyValueEntry) {
if opOf(e) {
ch.VerdictWritten(ctx, e.Key(), e.Value())
} else {
ch.VerdictDeleted(ctx, e.Key())
}
})
}()
go func() { errs <- follow(ctx, w, each) }()
return nil
}
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "checker_first_views reads nothing and checker_first_view_again is not heard", logger.Printf))
// The checks, the tries and the hand asks, once every first view is answered (the lead's ruling for DLV-M2): a check
// judged without the candidates, the modules, the manifests, the published packages, the predefined keys or the
// placement would be another check.
go func() {
select {
case <-ctx.Done():
return
case <-reader.Ready():
}
if err := afterFirstViews(); err != nil {
errs <- err
}
}()
logger.Printf("started on %s", cred.Node)
go func() { errs <- follow(ctx, w, each) }()
select {
case <-ctx.Done():
ch.Wait()
@@ -0,0 +1,36 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
"mesh-delivery-checker/internal/check"
)
// seatRenames asks the seat mesh-config's verb renames as the checker's own account (its `invokes`
// seat:mesh-config.renames; design 32). A call nobody answers is a holder serving no renames (the verb is optional), said
// as check.ErrRenamesNotServed with the bus's words; any other error is an ask that could not be made.
type seatRenames struct {
call func(key string, body any) (json.RawMessage, error)
}
func (s seatRenames) Renames(_ context.Context, module string) (meshconfig.RenamesAnswer, error) {
raw, err := s.call(check.RenamesSeat, meshconfig.RenamesInput{Module: module})
if errors.Is(err, nats.ErrNoResponders) {
return meshconfig.RenamesAnswer{}, fmt.Errorf("%w (nothing answers it: %v)", check.ErrRenamesNotServed, err)
}
if err != nil {
return meshconfig.RenamesAnswer{}, err
}
var a meshconfig.RenamesAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return meshconfig.RenamesAnswer{}, fmt.Errorf("%s answered what is not its answer: %w", check.RenamesSeat, err)
}
return a, nil
}
+1
View File
@@ -12,6 +12,7 @@ require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-2
require (
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000
@@ -45,6 +45,8 @@ type Case struct {
notesMu sync.Mutex
// warnings are, by pull request, what part of its check did not run (Case.warn).
warnings map[string]string
// seriesOf are, by pull request, the changes of a package series it makes (series.go), named in its status line.
seriesOf map[string][]string
notes []string
// asks are the ids of the asks the check put on the build seats' streams, in order: a try names them (Try.Asks).
asks []string
@@ -19,6 +19,7 @@ import (
"unicode/utf8"
gobuild "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/composer"
@@ -1035,8 +1036,27 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
// mail's tool while mail awaits web's database: both left out, so neither is refused (ADR 0614 §3, §6).
cycle string
trunkCycle bool
// series is the series of web's package the change raises it to, and the words its status line names (DLV-C4).
series, seriesWords string
// renames is what the change does to web's renamed-from ("bring", "drop") and what the seat mesh-config's verb
// renames answers ("served", "open" on g14, "not served", "unasked" with no seat to ask, "error"); DLV-C5.
renames, seat string
// unparsed is the indexer's words of why the pull request's head's seat definition of web-api does not parse.
unparsed string
}{
{name: "a seat definition that does not parse", unparsed: "seats/web-api/seat.json: unexpected end of JSON input",
fails: "novox/mesh-delivery#7: the seat definition of web-api (module web-contracts, go) does not parse: seats/web-api/seat.json: unexpected end of JSON input"},
{name: "a rename brought, the verb not served", renames: "bring", seat: "not served",
fails: "web (novox/mesh-delivery#7) renames new-key from old-key: refused, the holder of the seat mesh-config does not serve its verb renames"},
{name: "a rename brought, the verb served", renames: "bring", seat: "served"},
{name: "a renamed-from dropped while its window is open", renames: "drop", seat: "open",
fails: "web (novox/mesh-delivery#7) drops renamed-from old-key of new-key while its rename window is open on g14"},
{name: "a renamed-from dropped once its window closed", renames: "drop", seat: "served"},
{name: "a rename the check cannot ask of", renames: "bring", seat: "error", fails: "the check cannot ask seat:mesh-config.renames: the bus said no"},
{name: "a rename with no seat to ask", renames: "drop", seat: "unasked", fails: "this checker cannot ask seat:mesh-config.renames"},
{name: "accepted"},
{name: "a package series raised", series: "0.2", seriesWords: "web/pkg series 0.1 → 0.2"},
{name: "a package series kept", series: "0.1"},
{name: "an untouched manifest's problem the trunk had", untouched: "mail says no credential form", trunkMail: "mail says no credential form"},
{name: "a problem the change causes in an untouched manifest", untouched: "mail reads a state web no longer keeps",
fails: "refuses the manifest of mail: mail reads a state web no longer keeps"},
@@ -1077,6 +1097,38 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): mail, strings.Repeat("b", 64): trunkWeb, web: changed}
}
if row.series != "" {
pkg := func(series string) *catalogue.Build {
return &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "pkg", Kind: catalogue.ArtifactPackage, Language: "go", Series: series}}}
}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web", Build: pkg("")},
web: {Module: "web", Build: pkg(row.series)}}
}
if row.renames != "" {
renamed := map[string]catalogue.SettingDeclaration{"new-key": {Type: catalogue.ConfigString, RenamedFrom: "old-key"}}
plain := map[string]catalogue.SettingDeclaration{"new-key": {Type: catalogue.ConfigString}}
trunkWeb, headWeb := catalogue.Manifest{Module: "web", Settings: plain}, catalogue.Manifest{Module: "web", Settings: renamed}
if row.renames == "drop" {
trunkWeb, headWeb = headWeb, trunkWeb
}
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): trunkWeb, web: headWeb}
// The verb faked from its definition: the answer of the seat's fixture (mesh-contracts seats/mesh-config).
fake := fakeRenames{}
switch row.seat {
case "not served":
fake.err = fmt.Errorf("%w (nothing answers it)", ErrRenamesNotServed)
case "error":
fake.err = errors.New("the bus said no")
case "open":
fake.answer = meshconfig.RenamesAnswer{Renames: []meshconfig.RenamesAnswerRenamesItem{{Module: "web", From: "old-key", To: "new-key", At: "2026-11-04T12:00:00Z", Open: []string{"g14"}}}}
}
if row.seat != "unasked" {
b.ex.Renames = fake
}
}
if row.unparsed != "" {
b.ex.SeatDefinitions = fakeSeatDefinitions{"novox/mesh-delivery#7": {{Seat: "web-api", Module: "web-contracts", Directory: "go", Why: row.unparsed}}}
}
var judged []delivery.ComposeJob
answer := b.asks.Answer
b.asks.Answer = func(a delivery.StepAsk) delivery.Step {
@@ -1094,7 +1146,11 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
p := pullOf(repoA, 7, head1)
p.Modules = []string{"web"}
p.Fps = map[string]delivery.CandidateModule{"web": {FP2: "src2:" + strings.Repeat("f", 64), Manifest: web}}
v := b.judge(t, caseOf(t, p)).Verdict
cs := caseOf(t, p)
v := b.judge(t, cs).Verdict
if line := Line(v, cs, cs.Pulls[0], nil, nil, nil); row.seriesWords != "" && !strings.Contains(line, row.seriesWords) || row.seriesWords == "" && strings.Contains(line, "series") {
t.Errorf("%s: the line %q, want it to name %q", row.name, line, row.seriesWords)
}
if len(judged) != 1 || judged[0].Fetch["repository"] != DeliveryRepository || judged[0].Fetch["commit"] != trunk ||
judged[0].Package != JudgePackage || judged[0].Dir != JudgeDir {
t.Fatalf("%s: judged %+v (verdict %+v)", row.name, judged, v)
@@ -1119,3 +1175,20 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
t.Error("which merge changes the judge")
}
}
// fakeRenames is the seat mesh-config's verb renames as its definition answers it.
type fakeRenames struct {
answer meshconfig.RenamesAnswer
err error
}
func (f fakeRenames) Renames(context.Context, string) (meshconfig.RenamesAnswer, error) {
return f.answer, f.err
}
// fakeSeatDefinitions are the indexer's unparsed seat definitions by pull request (pullKey).
type fakeSeatDefinitions map[string][]UnparsedSeat
func (f fakeSeatDefinitions) Unparsed(_ context.Context, repository string, pull int) ([]UnparsedSeat, error) {
return f[fmt.Sprintf("%s#%d", repository, pull)], nil
}
@@ -128,6 +128,11 @@ type Examiner struct {
// Manifests are the indexer's manifest digests by module: the catalogue a change's manifests are judged in by the
// indexer's judge (manifests_judge.go, novox/hq ADR 0578 §2); nil judges no manifest.
Manifests TrunkManifests
// SeatDefinitions reads the indexer's seat definitions of a pull request (seatdefs.go, design 26); nil reads none.
SeatDefinitions SeatDefinitions
// Renames asks the seat mesh-config's verb renames for a change that brings or drops a `renamed-from` (renames.go,
// design 32); nil fails such a change, saying it cannot ask.
Renames Renames
// Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq
// ADR 0603 §2); nil works out no graph.
Read ManifestReader
@@ -269,7 +274,8 @@ func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) {
// Every member and level runs to its end, and every manifest the change touches is judged by the indexer's judge (ADR
// 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02).
tests := st.judgeAll(ctx)
if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx))); v != nil {
st.seriesChanges(ctx) // DLV-C4: named in the verdict's line, whatever the verdict
if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx), st.judgeRenames(ctx), st.refuseUnparsed(ctx))); v != nil {
return st.finish(ctx, *v), nil
}
return st.finish(ctx, delivery.Verdict{State: delivery.VerdictPass}), nil
@@ -117,18 +117,20 @@ type pieces struct {
carried string
// warning says that a part of the check did not run, for instance that an older build module could not refuse a compiled program; never dropped.
warning string
// series names each change of a package series the pull request makes (design 32, DLV-C4); never dropped.
series string
}
// pullKey names a pull request of a case: `<owner/repo>#<number>`.
func pullKey(p Pull) string { return fmt.Sprintf("%s#%d", p.Repository, p.Number) }
func (p pieces) join(drop int) string {
parts := []string{p.head, p.commits, p.carried, p.warning, p.counts, p.judged}
parts := []string{p.head, p.commits, p.carried, p.warning, p.counts, p.judged, p.series}
switch {
case drop < 3 && p.times != "":
parts = append(parts, p.times)
case drop == 3 && p.shortTimes != "":
parts = []string{p.head, p.commits, p.carried, p.warning, p.shortCounts, p.judged, p.shortTimes}
parts = []string{p.head, p.commits, p.carried, p.warning, p.shortCounts, p.judged, p.series, p.shortTimes}
}
if p.over != "" {
parts = append(parts, p.over)
@@ -244,6 +246,7 @@ func carriedWords(from map[string]string, v delivery.Verdict, cs *Case, p Pull)
func parts(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) pieces {
var pc pieces
pc.judged = judged[pullKey(p)]
pc.series = seriesWords(cs, p)
if cs != nil {
pc.warning = cs.Warning(pullKey(p))
}
@@ -356,7 +359,7 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
pc := parts(v, cs, p, builds, slowest, judged)
if v.State == delivery.VerdictFail {
// The head gives way first to the commits and what is never dropped: its count and "and N more" stay whole.
kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged)
kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged, pc.series)
pc.head = failedHead(v, MaxLine-utf8.RuneCountInString(" | "+kept))
}
for drop := 0; drop <= 4; drop++ {
@@ -364,13 +367,13 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
return s
}
}
if pc.judged != "" {
if pc.judged != "" || pc.series != "" {
for _, counts := range []string{pc.shortCounts, ""} {
if s := joinSet(pc.head, pc.commits, counts, pc.judged); utf8.RuneCountInString(s) <= MaxLine {
if s := joinSet(pc.head, pc.commits, counts, pc.judged, pc.series); utf8.RuneCountInString(s) <= MaxLine {
return s
}
}
rest := " | " + joinSet(pc.commits, pc.judged)
rest := " | " + joinSet(pc.commits, pc.judged, pc.series)
room := MaxLine - utf8.RuneCountInString(rest) - 1
if head := []rune(pc.head); room > 0 && len(head) > room {
return string(head[:room]) + "…" + rest
@@ -378,7 +381,7 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
}
if v.State == delivery.VerdictFail {
// A failure's head, sized above, and the commits fit: the counts and the composition are in the report.
if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged); utf8.RuneCountInString(s) <= MaxLine {
if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged, pc.series); utf8.RuneCountInString(s) <= MaxLine {
return s
}
}
@@ -0,0 +1,127 @@
package check
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A config value renamed (design 32 *A config value renamed*, DLV-C5): the checker asks the seat mesh-config's verb
// renames, never the module, for every change that brings `renamed-from` or drops one. A holder that does not serve the
// verb refuses `renamed-from`, naming that; while a rename's window is open, dropping its `renamed-from` is refused,
// naming the nodes holding it open; a check that cannot ask fails, saying so.
// RenamesSeat is the verb the checker asks, as its manifest's `invokes` names it.
var RenamesSeat = "seat:" + meshconfig.Seat + "." + meshconfig.VerbRenames
// ErrRenamesNotServed is the answer of Renames when the seat's holder serves no verb renames.
var ErrRenamesNotServed = errors.New("the holder of the seat " + meshconfig.Seat + " does not serve its verb " + meshconfig.VerbRenames)
// Renames asks the seat mesh-config's verb renames for a module's open renames.
type Renames interface {
Renames(ctx context.Context, module string) (meshconfig.RenamesAnswer, error)
}
// renameChange is what a pull request's head does to a module's `renamed-from` against the trunk: the declarations it
// brings (key from old name) and the ones it drops (key, old name).
type renameChange struct {
brought, dropped map[string]string
}
// renamesOf compares a module's config declarations at the trunk and at the head.
func renamesOf(was, now catalogue.Manifest) renameChange {
rc := renameChange{brought: map[string]string{}, dropped: map[string]string{}}
for key, d := range now.Settings {
if d.RenamedFrom != "" && was.Settings[key].RenamedFrom != d.RenamedFrom {
rc.brought[key] = d.RenamedFrom
}
}
for key, d := range was.Settings {
if d.RenamedFrom != "" && now.Settings[key].RenamedFrom != d.RenamedFrom {
rc.dropped[key] = d.RenamedFrom
}
}
return rc
}
// judgeRenames fails the check for a rename the seat refuses, a window a change would drop, or an ask that cannot be made.
func (st *state) judgeRenames(ctx context.Context) *delivery.Verdict {
if st.c.Try != nil {
return nil
}
var out *delivery.Verdict
failed := func(words string) {
f := delivery.FailedFirst{Machine: meshconfig.Seat, Words: words}
if out == nil {
out = &delivery.Verdict{State: delivery.VerdictFail, Failed: f}
return
}
addFailure(out, f)
}
for _, p := range st.c.Pulls {
for _, module := range p.Modules {
was, now, ok, err := st.manifestPair(ctx, p, module)
if err != nil {
failed(fmt.Sprintf("%s (%s): its config renames cannot be judged: %v", module, pullKey(p), err))
continue
}
if !ok {
was = catalogue.Manifest{} // a module new on the pull request brings every rename it declares
if now, ok = st.headManifest(ctx, p, module); !ok {
continue
}
}
rc := renamesOf(was, now)
if len(rc.brought) == 0 && len(rc.dropped) == 0 {
continue
}
if st.j.Renames == nil {
failed(fmt.Sprintf("%s (%s) changes a renamed-from, and this checker cannot ask %s", module, pullKey(p), RenamesSeat))
continue
}
answer, err := st.j.Renames.Renames(ctx, module)
switch {
case errors.Is(err, ErrRenamesNotServed):
for _, key := range slices.Sorted(maps.Keys(rc.brought)) {
failed(fmt.Sprintf("%s (%s) renames %s from %s: refused, %v, so the value cannot be moved", module, pullKey(p), key, rc.brought[key], err))
}
for _, key := range slices.Sorted(maps.Keys(rc.dropped)) {
failed(fmt.Sprintf("%s (%s) drops renamed-from %s of %s: refused, %v, so whether its window is open is not known", module, pullKey(p), rc.dropped[key], key, err))
}
continue
case err != nil:
failed(fmt.Sprintf("%s (%s) changes a renamed-from, and the check cannot ask %s: %v", module, pullKey(p), RenamesSeat, err))
continue
}
for _, key := range slices.Sorted(maps.Keys(rc.dropped)) {
for _, r := range answer.Renames {
if r.Module == module && r.From == rc.dropped[key] && r.To == key && len(r.Open) > 0 {
open := slices.Sorted(slices.Values(r.Open))
failed(fmt.Sprintf("%s (%s) drops renamed-from %s of %s while its rename window is open on %s", module, pullKey(p), r.From, key, strings.Join(open, ", ")))
}
}
}
st.c.note(fmt.Sprintf("%s.%s answered for %s (%s)", meshconfig.Seat, meshconfig.VerbRenames, module, pullKey(p)))
}
}
return out
}
// headManifest is a module's manifest at the pull request's head, new on it: by its candidate's digest, else the forge's.
func (st *state) headManifest(ctx context.Context, p Pull, module string) (catalogue.Manifest, bool) {
if digest := p.Fps[module].Manifest; digest != "" && st.j.Read != nil {
if st.j.Manifests != nil && st.j.Manifests.ManifestDigests()[module] == digest {
return catalogue.Manifest{}, false // unchanged
}
m, err := st.j.Read.Manifest(ctx, digest)
return m, err == nil
}
return catalogue.Manifest{}, false
}
@@ -0,0 +1,55 @@
package check
import (
"context"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A pull request whose head's seat definition does not parse fails its check, naming the seat and where it lives (design
// 26; the lead's ruling on A2i's finding 1): the indexer keeps such a definition as the pull request's entry of its state
// seat-definitions with `unparsed` set, and cannot refuse the manifest itself, whose digest the trunk may share.
// UnparsedSeat is one seat definition of a pull request's head that does not parse: the seat (the contracts module's name
// when its index did not parse), its module's directory and the indexer's words of why.
type UnparsedSeat struct {
Seat, Module, Directory, Why string
}
// SeatDefinitions reads the indexer's seat definitions of one pull request (its state seat-definitions, through its
// first view, DLV-M2).
type SeatDefinitions interface {
Unparsed(ctx context.Context, repository string, pull int) ([]UnparsedSeat, error)
}
// refuseUnparsed fails the check of each pull request with a seat definition that does not parse; a state that cannot be
// read fails it too, saying so.
func (st *state) refuseUnparsed(ctx context.Context) *delivery.Verdict {
if st.j.SeatDefinitions == nil || st.c.Try != nil {
return nil
}
var out *delivery.Verdict
failed := func(words string) {
f := delivery.FailedFirst{Machine: SeatDefinitionsMachine, Words: words}
if out == nil {
out = &delivery.Verdict{State: delivery.VerdictFail, Failed: f}
return
}
addFailure(out, f)
}
for _, p := range st.c.Pulls {
bad, err := st.j.SeatDefinitions.Unparsed(ctx, p.Repository, p.Number)
if err != nil {
failed(fmt.Sprintf("%s: the indexer's seat definitions of it cannot be read, so none is known to parse: %v", pullKey(p), err))
continue
}
for _, u := range bad {
failed(fmt.Sprintf("%s: the seat definition of %s (module %s, %s) does not parse: %s", pullKey(p), u.Seat, u.Module, u.Directory, u.Why))
}
}
return out
}
// SeatDefinitionsMachine names what refused a seat definition in a verdict's failure.
const SeatDefinitionsMachine = "seat-definitions"
@@ -0,0 +1,110 @@
package check
import (
"context"
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A change of a package series is named in the verdict, so the review of the change sees it (design 32 *A package, its
// series*; DLV-C4): for each module a pull request touches, every artifact both its trunk manifest and its head's
// manifest declare whose series differs, as `<module>/<artifact> series <trunk> → <head>`. An artifact that names none
// is in catalogue.DefaultSeries. A manifest that cannot be read is said in the same words, never left out.
// seriesChanges names, on the case per pull request, each change of an artifact's series the pull request makes.
func (st *state) seriesChanges(ctx context.Context) {
for _, p := range st.c.Pulls {
var words []string
for _, module := range p.Modules {
was, now, ok, err := st.manifestPair(ctx, p, module)
if err != nil {
words = append(words, fmt.Sprintf("%s: its series not compared (%v)", module, err))
continue
}
if ok {
words = append(words, seriesMoved(module, was, now)...)
}
}
if len(words) > 0 {
st.c.series(pullKey(p), words)
}
}
}
// manifestPair is a touched module's trunk manifest and its manifest at the pull request's head: the head's by the digest
// its candidate names, else read from the forge at the head. ok is false when the module is new on the pull request,
// unchanged, or not read here.
func (st *state) manifestPair(ctx context.Context, p Pull, module string) (was, now catalogue.Manifest, ok bool, err error) {
if st.j.Read == nil || st.j.Manifests == nil {
return was, now, false, nil
}
trunk := st.j.Manifests.ManifestDigests()[module]
digest := p.Fps[module].Manifest
if trunk == "" || digest == trunk {
return was, now, false, nil
}
if was, err = st.j.Read.Manifest(ctx, trunk); err != nil {
return was, now, false, fmt.Errorf("the trunk's manifest not read: %w", err)
}
switch {
case digest != "":
if now, err = st.j.Read.Manifest(ctx, digest); err != nil {
return was, now, false, fmt.Errorf("the head's manifest not read: %w", err)
}
case st.j.Forge != nil:
var found bool
if now, _, found, err = st.manifestAtHead(ctx, p, module); err != nil || !found {
return was, now, false, err
}
default:
return was, now, false, nil
}
return was, now, true, nil
}
// seriesMoved are the artifacts of module whose series the head's manifest changes from the trunk's.
func seriesMoved(module string, was, now catalogue.Manifest) []string {
if was.Build == nil || now.Build == nil {
return nil
}
before := map[string]string{}
for _, a := range was.Build.Artifacts {
before[a.Name] = a.SeriesOf()
}
var out []string
for _, a := range now.Build.Artifacts {
if old, ok := before[a.Name]; ok && old != a.SeriesOf() {
out = append(out, fmt.Sprintf("%s/%s series %s → %s", module, a.Name, old, a.SeriesOf()))
}
}
slices.Sort(out)
return out
}
// series keeps, of a pull request (pullKey), the words of the series changes it makes.
func (c *Case) series(key string, words []string) {
c.notesMu.Lock()
defer c.notesMu.Unlock()
if c.seriesOf == nil {
c.seriesOf = map[string][]string{}
}
c.seriesOf[key] = words
}
// Series are the series changes a pull request (pullKey) makes, as the check named them; none when it makes none.
func (c *Case) Series(key string) []string {
c.notesMu.Lock()
defer c.notesMu.Unlock()
return slices.Clone(c.seriesOf[key])
}
// seriesWords is a pull request's series changes for its status line.
func seriesWords(c *Case, p Pull) string {
if c == nil {
return ""
}
return strings.Join(c.Series(pullKey(p)), ", ")
}
+17 -6
View File
@@ -102,20 +102,29 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
t.Errorf("module = %q", m.Module)
}
// delivery.CheckerKeptHistory, CheckerOwedHistory, PreviewsHistory, CheckerVerdictsHistory; the names follow the SDK's buckets.
if want := []state{{Name: "asks", History: 1}, {Name: "hearing", History: 1}, {Name: "kept", History: 1}, {Name: "owed", History: 5}, {Name: "previews", History: 5}, {Name: "tries", History: 1}, {Name: "verdicts", History: 5}}; !reflect.DeepEqual(m.State, want) {
if want := []state{{Name: "asks", History: 1}, {Name: "hearing", History: 1}, {Name: "kept", History: 1}, {Name: "owed", History: 5}, {Name: "previews", History: 5}, {Name: "tries", History: 1}, {Name: "verdicts", History: 5}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) {
t.Errorf("state = %+v, want %+v", m.State, want)
}
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-node.steps", "mesh-build-node.toolchains",
"mesh-delivery-assigner.placement", "mesh-delivery-builder.builds", "mesh-delivery-gate.source", "mesh-delivery-gate.turns", "mesh-delivery-indexer.candidates",
"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.packages", "mesh-delivery-indexer.predefined", "mesh-delivery-watchman.presence", "node-engine.have"}; !reflect.DeepEqual(sorted(m.Reads), want) {
"mesh-delivery-builder.builds", "mesh-delivery-gate.source", "mesh-delivery-gate.turns", "mesh-delivery-watchman.presence", "node-engine.have"}; !reflect.DeepEqual(sorted(m.Reads), want) {
t.Errorf("reads = %v, want %v", m.Reads, want)
}
// DLV-M2 (ADR 0615 §1, ADR 0596): the indexer's candidates, modules, manifests, packages and predefined keys and the
// assigner's placement are asked by their read tools and followed by their views families, never read.
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)
}
}
// 1887/A5b: the checker reads nothing of the controller's plans-building (ADR 0483 superseded by ADR 0525 §7), calls no
// build module's tool and holds no capacity: it puts check asks on the Go and Node build seats (ADR 0525 §1, §4).
// The forge's reads for a mesh-sdk pin (ADR 0484 clause 9), and of the node seat node-engine its one verb
// compose-candidate, asked of every placed node (ADR 0593 §7), never the seat's other verbs.
if want := []string{"gitea.gitea_commits", "gitea.gitea_compare", "gitea.gitea_get_file", "gitea.gitea_list_branches",
"seat:node-engine.compose-candidate"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
"mesh-delivery-assigner.assigner_read_placement", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined",
"mesh-delivery-indexer.indexer_read_seat_definitions",
"seat:mesh-config.renames", "seat:node-engine.compose-candidate"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
t.Errorf("invokes = %v, want the forge's reads and compose-candidate", m.Invokes)
}
if want := []string{"node-build-go", "node-build-node"}; !reflect.DeepEqual(sorted(m.Uses), want) {
@@ -123,7 +132,9 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
}
// The checker is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it starts.
// And a group's check by the package registry seats' published of its tagged versions (design 51 *Tagged versions*).
if want := []string{"go-package-registry.published", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", "npm-package-registry.published"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
if want := []string{"go-package-registry.published", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported",
"mesh-delivery-assigner.views.placement", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.packages", "mesh-delivery-indexer.views.predefined", "mesh-delivery-indexer.views.seat-definitions", "npm-package-registry.published"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" {
@@ -141,7 +152,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
// The read tools (issue 1686) are served by the tools bundle, not by the checker's process.
// The try's tools (ADR 0501 §5; the end by hand, issue 734) beside them: mesh-controller's try calls them. A lost check
// is asked again by hand (checker_ask_again).
if want := []string{"checker_ask_again", "checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_try", "checker_try_end", "checker_try_read", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) {
if want := []string{"checker_ask_again", "checker_first_view_again", "checker_first_views", "checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_try", "checker_try_end", "checker_try_read", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
// The try is the checker's own seat's verbs (ADR 0547 §10, issue 774), served beside the tools by the same code.
+20 -7
View File
@@ -17,6 +17,13 @@
"go-package-registry.published",
"mesh-build-go.toolchain.reported",
"mesh-build-node.toolchain.reported",
"mesh-delivery-assigner.views.placement",
"mesh-delivery-indexer.views.candidates",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.packages",
"mesh-delivery-indexer.views.predefined",
"mesh-delivery-indexer.views.seat-definitions",
"npm-package-registry.published"
],
"streams": [
@@ -30,22 +37,18 @@
{ "name": "owed", "history": 5 },
{ "name": "previews", "history": 5 },
{ "name": "tries", "history": 1 },
{ "name": "verdicts", "history": 5 }
{ "name": "verdicts", "history": 5 },
{ "name": "first-views", "history": 1 },
{ "name": "findings", "history": 1 }
],
"reads": [
"mesh-build-go.steps",
"mesh-build-go.toolchains",
"mesh-build-node.steps",
"mesh-build-node.toolchains",
"mesh-delivery-assigner.placement",
"mesh-delivery-builder.builds",
"mesh-delivery-gate.source",
"mesh-delivery-gate.turns",
"mesh-delivery-indexer.candidates",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.packages",
"mesh-delivery-indexer.predefined",
"mesh-delivery-watchman.presence",
"node-engine.have"
],
@@ -59,6 +62,8 @@
"checker_previews",
"checker_pull",
"checker_status",
"checker_first_views",
"checker_first_view_again",
"checker_try",
"checker_try_end",
"checker_try_read",
@@ -69,6 +74,14 @@
"gitea.gitea_compare",
"gitea.gitea_get_file",
"gitea.gitea_list_branches",
"mesh-delivery-assigner.assigner_read_placement",
"mesh-delivery-indexer.indexer_read_candidates",
"mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_packages",
"mesh-delivery-indexer.indexer_read_predefined",
"mesh-delivery-indexer.indexer_read_seat_definitions",
"seat:mesh-config.renames",
"seat:node-engine.compose-candidate"
],
@@ -35,6 +35,13 @@ func TestTheJudge(t *testing.T) {
"two": `{"module":"two","version":"1","seats":[{"name":"thing","scope":"mesh"}]}`,
"ok": `{"module":"ok","version":"1"}`},
want: map[string]string{"one": "already declares", "two": "already declares"}},
// The lead's ruling 5 for A2h: a module that claims a seat and serves its verbs keeps tools of its own beside them
// (the enrolment's first-view tools, DLV-M6), and the judge takes it.
{name: "a claimed seat's module with tools of its own",
raws: map[string]string{
"def": `{"module":"def","version":"1","seats":[{"name":"enrolment","scope":"mesh","serves":["issue","again"]}]}`,
"enrolment": `{"module":"enrolment","version":"1","claims":[{"name":"enrolment","scope":"mesh","serves":["issue","again"]}],` +
`"tools":["enrolment_first_views","enrolment_first_view_again"]}`}},
} {
raws := map[string][]byte{}
for k, v := range row.raws {
@@ -13,6 +13,7 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-teller/internal/ends"
)
@@ -102,7 +103,11 @@ func askEnd(s stdio.KeptState, bound time.Duration) asker {
}
func main() {
if err := stdio.Serve("", Tools(askEnd(stdio.State(ends.State), 25*time.Second), time.Now)); err != nil {
// teller_first_views and teller_first_view_again: where the teller's first view of the assigner's asks stands, and
// asking it again by hand (ADR 0615, design 25 § 12.2).
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), time.Now)...)
if err := stdio.Serve("", tools); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-teller-tools] %v\n", err)
os.Exit(1)
}
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-delivery/sdk/firstview/firstviewtest"
"os"
"path/filepath"
"strings"
@@ -184,7 +185,10 @@ func fullConf(dir string) string {
seatAsk, seatCancel, seatCondition, "$JS.API.DIRECT.GET.KV_"+messengerAsks+".$KV."+messengerAsks+".mesh-delivery-teller.>")
// The durable consumer on EVENTS the controller makes for the seat it uses, pulled and acknowledged (issue 1126).
teller = append(teller, "$JS.API.CONSUMER.INFO.EVENTS."+tellerConsumer, "$JS.API.CONSUMER.MSG.NEXT.EVENTS."+tellerConsumer, "$JS.ACK.EVENTS."+tellerConsumer+".>")
teller = append(append(teller, readOf(assignerAsks)...), readOf(viewAnswers)...) // issue 1297 B10: the manifest's reads
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, firstviewtest.Grants(delivery.AssignerModule, "asks", tellerConsumer)...), keepsOf(firstViewsBucket)...), keepsOf(findingsBucket)...)
view := append(append(append(readOf(tellerAnswers), readOf(gateAsksKV)...), readOf(assignerAsks)...), keepsOf(viewAnswers)...)
assigner := keepsOf(assignerAsks)
tools := append(keepsOf(gateAsksKV), readOf(tellerAnswers)...)
@@ -251,7 +255,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: delivery.AsksHistory, tellerAnswers: delivery.AnswersHistory, tellerAsked: 1, ends.Bucket: 1, messengerAsks: 1, assignerAsks: delivery.AssignerAsksHistory, viewAnswers: delivery.ViewAnswersHistory} {
for name, history := range map[string]uint8{gateAsksKV: delivery.AsksHistory, tellerAnswers: delivery.AnswersHistory, tellerAsked: 1, ends.Bucket: 1, messengerAsks: 1, assignerAsks: delivery.AssignerAsksHistory, viewAnswers: delivery.ViewAnswersHistory, firstViewsBucket: 1, findingsBucket: 1} {
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil {
t.Fatal(err)
}
@@ -267,6 +271,8 @@ func startFull(t *testing.T) *fullBed {
AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
// The assigner as the owner of its asks: its read tool and its views family (ADR 0615).
firstviewtest.Serve(t, ctx, js, ctl, delivery.AssignerModule, "asks", tellerConsumer)
return &fullBed{srv: srv, ctl: ctl, js: js, ctx: ctx}
}
@@ -18,9 +18,13 @@ import (
"syscall"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/sdk/firstview"
"git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -83,18 +87,28 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// 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)
assignerKV, viewKV := process.OptionalBucket(ctx, js, delivery.AssignerAsksBucket, teller, logger.Printf), process.OptionalBucket(ctx, js, delivery.ViewAnswersBucket, teller, logger.Printf)
// The assigner's asks are its state, read by the first view of its read tool and then its views family, never its
// bucket (ADR 0615 §1, DLV-M3): the picture reads as the bucket did.
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
if err != nil {
return fmt.Errorf("the teller's connection for its tool calls: %w", err)
}
defer calls.Close()
reader := &firstview.Reader{Module: delivery.TellerModule, Call: calls.Call, Logf: logger.Printf}
findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf)
reader.Findings = findings.KV
assignerKV := jetstream.KeyValue(reader.Follow(delivery.AssignerModule, assignerAsksState))
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))
viewKV := process.OptionalBucket(ctx, js, delivery.ViewAnswersBucket, teller, logger.Printf)
// 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}
}
if assignerKV != nil {
t.Assigner = kvGate{ctx: ctx, kv: assignerKV}
go func() {
failed <- watchAsks(ctx, assignerKV, func(key string, _ uint64, _ []byte) { t.OnAssignerAsk(key) }, t.OnAssignerAskGone, nil)
}()
}
if assignerKV != nil && viewKV != nil {
t.Assigner = kvGate{ctx: ctx, kv: assignerKV}
go func() {
failed <- watchAsks(ctx, assignerKV, func(key string, _ uint64, _ []byte) { t.OnAssignerAsk(key) }, t.OnAssignerAskGone, nil)
}()
if viewKV != nil {
go func() {
failed <- watchAsks(ctx, viewKV, func(key string, _ uint64, _ []byte) { t.OnViewAnswer(key) }, func(string) {}, nil)
}()
@@ -131,5 +145,15 @@ const teller = "the asks and answers it holds are not carried until the teller i
// raised (manifest state entry asked, novox/hq ADR 0437 §4).
const askedBucket = delivery.TellerModule + "_asked"
// The teller's own states of its first view of the assigner's asks (views.StatusState) and of its findings, where the
// condition keeper raises a first view unanswered past its bound (ADR 0615 §6).
var (
firstViewsBucket = delivery.TellerModule + "_" + views.StatusState
findingsBucket = delivery.TellerModule + "_findings"
)
// assignerAsksState is the assigner's state of its asks (delivery.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"
+4
View File
@@ -20,3 +20,7 @@ require (
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
+21 -6
View File
@@ -40,6 +40,8 @@ type manifest struct {
Uses []string `json:"uses"`
State []state `json:"state"`
Reads []string `json:"reads"`
Invokes []string `json:"invokes"`
Consumes []string `json:"consumes"`
OwnSecrets map[string]string `json:"own-secrets"`
SecretsOwner string `json:"secrets-owner"`
Resources []map[string]any `json:"resources"`
@@ -80,13 +82,26 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1
}
// 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).
if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}}; !reflect.DeepEqual(m.State, want) {
// 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: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) {
t.Errorf("state = %+v, want %+v", m.State, want)
}
// The assigner's asks and the view's answers to them are read so the view's answer fills an ask that needs no proof (1297/B10).
if want := []string{"mesh-delivery-assigner.asks", "mesh-delivery-gate.asks", "mesh-delivery-view.answers"}; !reflect.DeepEqual(sorted(m.Reads), 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)
}
}
if want := []string{"mesh-delivery-assigner.assigner_read_asks"}; !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
if want := []string{"mesh-delivery-assigner.views.asks"}; !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) {
t.Errorf("uses = %v, want %v", m.Uses, want)
}
@@ -97,11 +112,11 @@ 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"}; !reflect.DeepEqual(m.Tools, want) {
if want := []string{"teller_end_ask", "teller_first_views", "teller_first_view_again"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
// No consumes, invokes, emits, streams, bus or seat (ADR 0437 §4).
for _, k := range []string{"consumes", "invokes", "streams", "logs", "emits", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} {
// No emits, streams, bus or seat (ADR 0437 §4).
for _, k := range []string{"streams", "logs", "emits", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the teller has none", k)
}
+12 -3
View File
@@ -3,15 +3,24 @@
"version": "1",
"slug": "teller",
"tools": [
"teller_end_ask"
"teller_end_ask",
"teller_first_views",
"teller_first_view_again"
],
"invokes": [
"mesh-delivery-assigner.assigner_read_asks"
],
"consumes": [
"mesh-delivery-assigner.views.asks"
],
"state": [
{ "name": "answers", "history": 5 },
{ "name": "asked", "history": 1 },
{ "name": "ends", "history": 1 }
{ "name": "ends", "history": 1 },
{ "name": "first-views", "history": 1 },
{ "name": "findings", "history": 1 }
],
"reads": [
"mesh-delivery-assigner.asks",
"mesh-delivery-gate.asks",
"mesh-delivery-view.answers"
],
@@ -14,8 +14,10 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/firstview"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-sdk/go/views"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -311,20 +313,30 @@ func read(state stdio.KeptState) view.StateRead {
}}
}
// ownersRead is the view's reading of the indexer's and the assigner's states: each through its owner's read tool, asked
// by the bundle on each call (ADR 0615 §1, design 25 § 12.2: the view keeps its tools and answers them by asking the
// owners), never their buckets.
func ownersRead(ask func(key string, body any) (json.RawMessage, error)) view.Reader {
of := func(owner, state string) view.StateRead { return view.OwnerRead(ask, firstview.ReadTool(owner, state)) }
return view.Reader{State: of(delivery.IndexerModule, "modules"), CandidatesState: of(delivery.IndexerModule, "candidates"),
WantsState: of(delivery.AssignerModule, "wants"), DifferencesState: of(delivery.AssignerModule, "differences"),
HeardState: of(delivery.IndexerModule, "heard"),
PredefinedState: of(delivery.IndexerModule, delivery.IndexerPredefinedState),
ConfigCandidatesState: of(delivery.IndexerModule, delivery.IndexerConfigCandidatesState),
PackagesState: of(delivery.IndexerModule, delivery.PackagesState),
GraphState: of(delivery.IndexerModule, graph.State)}
}
func main() {
r := view.Reader{State: read(stdio.State("mesh-delivery-indexer.modules")), Builds: read(stdio.State("mesh-delivery-builder.builds")), CandidatesState: read(stdio.State("mesh-delivery-indexer.candidates")),
WantsState: read(stdio.State("mesh-delivery-assigner.wants")), DifferencesState: read(stdio.State("mesh-delivery-assigner.differences")), HavesState: read(stdio.State("node-engine.have")),
ReachState: read(stdio.State("reach")),
HeardState: read(stdio.State("mesh-delivery-indexer.heard")),
SignersState: read(stdio.State("mesh-vault.signers")),
ConditionsState: read(stdio.State(view.StatusConditionsState)), AttemptsState: read(stdio.State(view.StatusAttemptsState)),
PresenceState: read(stdio.State(view.StatusPresenceState)), StuckState: read(stdio.State(view.StatusStuckState)),
PredefinedState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerPredefinedState)),
ConfigCandidatesState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState)),
PackagesState: read(stdio.State(delivery.IndexerModule + "." + delivery.PackagesState)),
GraphState: read(stdio.State(graph.Read)), SourceState: read(stdio.State(delivery.GateModule + ".source")),
ProvisionsState: read(stdio.State(provisions.ProvisionsRead)),
BuildLog: stdio.Log(delivery.BuildLogRead).Since}
r := ownersRead(stdio.Ask)
r.Builds, r.HavesState = read(stdio.State("mesh-delivery-builder.builds")), read(stdio.State("node-engine.have"))
r.ReachState = read(stdio.State("reach"))
r.SignersState = read(stdio.State("mesh-vault.signers"))
r.ConditionsState, r.AttemptsState = read(stdio.State(view.StatusConditionsState)), read(stdio.State(view.StatusAttemptsState))
r.PresenceState, r.StuckState = read(stdio.State(view.StatusPresenceState)), read(stdio.State(view.StatusStuckState))
r.SourceState = read(stdio.State(delivery.GateModule + ".source"))
r.ProvisionsState = read(stdio.State(provisions.ProvisionsRead))
r.BuildLog = stdio.Log(delivery.BuildLogRead).Since
u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")),
Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())}
for _, lang := range delivery.AskLanguages {
@@ -332,7 +344,10 @@ func main() {
u.Steps[lang] = read(stdio.State(module + "." + delivery.StepsState))
u.Pauses[lang] = read(stdio.State(module + "." + delivery.PausesState))
}
if err := stdio.Serve("", Tools(r, u)); err != nil {
// view_first_views and view_first_view_again: where the view's process stands with its first views of the indexer's and
// the assigner's states (ADR 0615 §6), and a re-read by hand.
tools := append(Tools(r, u), views.StatusTools(delivery.ViewModule, "view_first_views", "view_first_view_again", stdio.State(views.StatusState), time.Now)...)
if err := stdio.Serve("", tools); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] %v\n", err)
os.Exit(1)
}
@@ -56,6 +56,7 @@ const (
func TestModuleToolAnswersTheStoredValue(t *testing.T) {
t.Run("indexer_packages answers the published modules", packagesToolAnswersThePublishedModules)
t.Run("view_tiers names each module's wait", tiersToolSaysEachModulesWait)
t.Run("each tool answers through the owners' read tools as before", ownersAnswerTheToolsAsBefore)
r := fake{"a": withFP2, "b": withNone}.reader()
for key, want := range map[string]string{"a": withFP2, "b": withNone} {
out, err := run(t, r, view.ModuleReadTool, map[string]any{"module": key})
@@ -0,0 +1,131 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/firstview"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-view/internal/view"
)
// source is a fake owner state as a views.Source: what the owner's read tool answers from.
type source map[string]string
func (s source) Keys() ([]string, error) {
var out []string
for k := range s {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
func (s source) Get(key string) (*views.Entry, bool, error) {
v, ok := s[key]
if !ok {
return nil, false, nil
}
return &views.Entry{Value: json.RawMessage(v), Revision: uint64(len(key))}, true, nil
}
// ownersAnswerTheToolsAsBefore is DLV-V1: every view tool over the indexer's and the assigner's states answers through
// the owners' read tools (the real owner side, views.Read, over a fake state) what it answered reading the states.
func ownersAnswerTheToolsAsBefore(t *testing.T) {
cand := `{"v":1,"group":"feat/x","base":3,"heads":"h","pulls":[{"modules":["zsh"],"fps":{"zsh":{"fp2":"` + fp2B + `","manifest":"m"}}}]}`
pkgKey, err := delivery.PackagesKey("git.novox.be/novox/mesh-sdk/go")
if err != nil {
t.Fatal(err)
}
states := map[string]source{
delivery.IndexerModule + ".modules": {"a": withFP2, "b": withNone},
delivery.IndexerModule + ".candidates": {"feat/x": cand, "fix": `{"v":1,"group":"fix","base":3,"heads":"h","pulls":[{"modules":["netcheck"]}]}`},
delivery.AssignerModule + ".wants": {"want.ace.zsh": `{"fp":"` + fp2A + `"}`, "want.novox.zsh": `{"fp":"` + fp2B + `"}`,
"held." + keyB: `{"node":"ace","words":"failed"}`},
delivery.AssignerModule + ".differences": {"ace.zsh": `{"unexplained":true}`, "novox.zsh": `{"rule":3}`},
delivery.IndexerModule + "." + delivery.IndexerPredefinedState: {delivery.PredefinedListKey: `{"v":1,"keys":["dir:state"]}`},
delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState: {"novox.r.7": `{"v":1,"pull":"novox/r#7"}`},
delivery.IndexerModule + "." + delivery.PackagesState: {pkgKey: `{"v":1,"module":"git.novox.be/novox/mesh-sdk/go","commit":"c1"}`},
delivery.IndexerModule + "." + graph.State: {},
}
// The direct reading, as the tools read the states before (the reads the manifest no longer has).
st := func(name string) view.StateRead { return fake(states[name]).reader().State }
direct := view.Reader{State: st(delivery.IndexerModule + ".modules"), CandidatesState: st(delivery.IndexerModule + ".candidates"),
WantsState: st(delivery.AssignerModule + ".wants"), DifferencesState: st(delivery.AssignerModule + ".differences"),
PredefinedState: st(delivery.IndexerModule + "." + delivery.IndexerPredefinedState),
ConfigCandidatesState: st(delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState),
PackagesState: st(delivery.IndexerModule + "." + delivery.PackagesState),
GraphState: st(delivery.IndexerModule + "." + graph.State)}
// The owners' read tools, answered by the owner's own Read over the same state; any other address is refused.
tools := map[string]string{}
for name := range states {
owner, state, _ := strings.Cut(name, ".")
tools[firstview.ReadTool(owner, state)] = name
}
asked := map[string]int{}
ask := func(key string, body any) (json.RawMessage, error) {
name, ok := tools[key]
if !ok {
return nil, fmt.Errorf("no tool %s", key)
}
asked[key]++
args, _ := body.(map[string]any)
k, _ := args["key"].(string)
all, _ := args["values"].(bool)
after, _ := args["after"].(string)
owner, state, _ := strings.Cut(name, ".")
a, err := views.Read(owner, state, states[name], nil, k, all, after, time.Unix(0, 0))
if err != nil {
return nil, err
}
return json.Marshal(a)
}
owned := ownersRead(ask)
for _, row := range []struct {
tool string
args map[string]any
}{
{view.ModuleReadTool, map[string]any{"module": "a"}},
{view.ModuleReadTool, map[string]any{"module": "gone"}},
{view.ModulesReadTool, map[string]any{}},
{view.ModulesReadTool, map[string]any{"none2": true}},
{view.CandidateReadTool, map[string]any{"group": "feat/x"}},
{view.CandidatesReadTool, map[string]any{}},
{view.CandidatesReadTool, map[string]any{"module": "zsh"}},
{view.WantsReadTool, map[string]any{}},
{view.WantsReadTool, map[string]any{"node": "ace"}},
{view.WantReadTool, map[string]any{"node": "ace", "module": "zsh"}},
{view.DifferencesReadTool, map[string]any{"node": "novox"}},
{view.PredefinedReadTool, map[string]any{}},
{view.ConfigCandidatesReadTool, map[string]any{}},
{view.ConfigCandidatesReadTool, map[string]any{"key": "novox.r.7"}},
{view.PackagesReadTool, map[string]any{"module": "git.novox.be/novox/mesh-sdk/go"}},
{view.PackagesReadTool, nil},
} {
want, werr := run(t, direct, row.tool, row.args)
got, gerr := run(t, owned, row.tool, row.args)
wj, _ := json.Marshal(want)
gj, _ := json.Marshal(got)
if string(wj) != string(gj) || fmt.Sprint(werr) != fmt.Sprint(gerr) {
t.Errorf("%s %v through the owner's read tool = %s, %v; reading the state it was %s, %v", row.tool, row.args, gj, gerr, wj, werr)
}
}
// Each owner state the tools read was asked of its owner's read tool.
for tool, name := range tools {
if name != delivery.IndexerModule+"."+graph.State && asked[tool] == 0 {
t.Errorf("%s was never asked", tool)
}
}
// A history the owner's read tool does not answer is said, never answered as none.
if _, err := run(t, owned, view.ModuleHistoryTool, map[string]any{"module": "a"}); err == nil || !strings.Contains(err.Error(), "no history argument") {
t.Errorf("indexer_module_history with an owner answering no history: %v", err)
}
}
@@ -20,9 +20,22 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/sdk/firstview/firstviewtest"
"mesh-delivery-view/internal/view"
)
// serveOwners serves the six states of the indexer and the assigner the view's process follows by their first views
// (ADR 0615 §1, DLV-M4): each owner's read tool and views family, with the view's durable consumer (`n_mesh-delivery-view`).
// A bucket the test made is kept; the others are made empty.
func serveOwners(t *testing.T, ctx context.Context, js jetstream.JetStream, nc *nats.Conn) {
t.Helper()
for _, bucket := range ownersBuckets {
owner, state, _ := strings.Cut(bucket, "_")
firstviewtest.Serve(t, ctx, js, nc, owner, state, "n_"+delivery.ViewModule)
}
}
// TestProcessWholeSeesTheBuckets starts the program the way the node does (its entry, run, with its environment and a
// broker file) against an embedded bus holding the three buckets, and asks its socket.
func TestProcessWholeSeesTheBuckets(t *testing.T) { // 1134/AC8
@@ -99,6 +112,7 @@ func TestProcessWholeSeesTheBuckets(t *testing.T) { // 1134/AC8
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
@@ -198,6 +212,7 @@ func TestProcessWholeShowsWhatTheWatchmanSays(t *testing.T) { // 1168/AC5
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
@@ -264,6 +279,7 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
@@ -273,11 +289,11 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124
break
}
}
cands, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.CandidatesBucket, History: delivery.CandidatesHistory})
cands, err := js.KeyValue(ctx, delivery.CandidatesBucket)
if err != nil {
t.Fatal(err)
}
mods, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.ModulesBucket, History: delivery.ModulesHistory})
mods, err := js.KeyValue(ctx, delivery.ModulesBucket)
if err != nil {
t.Fatal(err)
}
@@ -350,6 +366,7 @@ func TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes(t *testing.T) { //
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
@@ -440,10 +457,11 @@ func TestProcessWholeListsEveryDifference(t *testing.T) { // 1297/B8 AC
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AssignerWantsBucket, History: delivery.AssignerWantsHistory})
kv, err := js.KeyValue(ctx, delivery.AssignerWantsBucket)
if err != nil {
t.Fatal(err)
}
@@ -537,6 +555,7 @@ func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 129
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
@@ -688,6 +707,7 @@ func TestProcessWholeShowsTheBacklogOfBuildAsks(t *testing.T) { // 1887/A8a AC
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
@@ -755,6 +775,7 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": filepath.Join(dir, "run", "cli.sock"), "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
@@ -851,6 +872,7 @@ func TestProcessWholeSaysModulesInMotion(t *testing.T) {
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": filepath.Join(dir, "run", "cli.sock"), "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
time.Sleep(2 * time.Second) // the view has read what stood: nothing
@@ -25,6 +25,9 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/firstview"
"mesh-delivery-view/internal/view"
)
@@ -106,13 +109,31 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
})
model.SetBacklog(view.BacklogOver(js, 5*time.Second))
// The indexer's and the assigner's states are their owners': read by the first view of the owner's read tool and then
// its views family, never their buckets (ADR 0615 §1, DLV-M4). An owner that is down never holds the start: a watch of
// its picture waits for the first view on its own goroutine, and until then the unanswered first view is the view's
// finding (ADR 0615 §6).
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
if err != nil {
return err
}
defer calls.Close()
reader := &firstview.Reader{Module: delivery.ViewModule, Call: calls.Call, Logf: logger.Printf}
reader.Findings = process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf).KV
for _, bucket := range ownersBuckets {
owner, state, _ := strings.Cut(bucket, "_")
reader.Follow(owner, state)
}
open := buckets{js: js, reader: reader}
for _, bucket := range []string{delivery.HaveBucket, delivery.PlacementBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket, delivery.BuilderBuildsBucket, delivery.AssignerWantsBucket, delivery.SourceBucket, delivery.AssignerAsksBucket, delivery.ViewAnswersBucket, delivery.TakenBucket} {
go follow(ctx, js, bucket, false, model, logger)
go follow(ctx, open, bucket, false, model, logger)
}
// The modules' and the source's history give each fingerprint's first commit and each head's source revision (ADR 0556).
// The modules' picture holds no history: from its first view on, each change it takes is the history (finding of DLV-M4).
for _, bucket := range []string{delivery.ModulesBucket, delivery.SourceBucket} {
go follow(ctx, js, bucket, true, model, logger)
go follow(ctx, open, bucket, true, model, logger)
}
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "view_first_views reads nothing and view_first_view_again is not heard", logger.Printf))
select {
case <-ctx.Done():
case err := <-webUp:
@@ -152,9 +173,34 @@ func putReach(ctx context.Context, js jetstream.JetStream, w view.ReachWrite) er
return err
}
// The view's own states of its first views (views.StatusState) and of its findings, where the condition keeper raises a
// first view unanswered past its bound (ADR 0615 §6).
var (
firstViewsBucket = delivery.ViewModule + "_" + views.StatusState
findingsBucket = delivery.ViewModule + "_findings"
)
// ownersBuckets are the indexer's and the assigner's states the view's process follows by their first views
// (`<owner>_<state>`); the view's tools ask the other six through the owners' read tools on each call.
var ownersBuckets = []string{delivery.PlacementBucket, delivery.AssignerWantsBucket, delivery.AssignerAsksBucket,
delivery.CandidatesBucket, delivery.ModulesBucket, delivery.TakenBucket}
// buckets opens what the view watches: an owner's state as its picture (firstview), any other bucket on the bus.
type buckets struct {
js jetstream.JetStream
reader *firstview.Reader
}
func (b buckets) KeyValue(ctx context.Context, bucket string) (jetstream.KeyValue, error) {
if p := b.reader.Picture(bucket); p != nil {
return p, nil
}
return b.js.KeyValue(ctx, bucket)
}
// follow watches a bucket with its last values first and then each change, for as long as ctx lives: a bucket that is
// not there yet, or a watch that ends, is tried again.
func follow(ctx context.Context, js jetstream.JetStream, bucket string, history bool, model *view.Model, logger *log.Logger) {
func follow(ctx context.Context, js process.BucketOpener, bucket string, history bool, model *view.Model, logger *log.Logger) {
for ctx.Err() == nil {
if err := watchOnce(ctx, js, bucket, history, model); err != nil && ctx.Err() == nil {
logger.Printf("cannot watch %s, trying again: %v", bucket, err)
@@ -166,7 +212,7 @@ func follow(ctx context.Context, js jetstream.JetStream, bucket string, history
}
}
func watchOnce(ctx context.Context, js jetstream.JetStream, bucket string, history bool, model *view.Model) error {
func watchOnce(ctx context.Context, js process.BucketOpener, bucket string, history bool, model *view.Model) error {
kv, err := js.KeyValue(ctx, bucket)
if err != nil {
return err
@@ -0,0 +1,111 @@
package view
import (
"encoding/json"
"fmt"
"time"
)
// OwnerRead reads an owner's state through the owner's ordinary read tool (design 25 § 12.2, ADR 0615 §1, DLV-M4): the
// view's tools answer the indexer's and the assigner's states by asking it, never by reading their buckets. ask is the
// bundle's ask of another module's tool (mesh-sdk stdio Ask), tool the read tool as the manifest's `invokes` names it
// (`<owner>.<short>_read_<state>`). With no argument the tool answers the keys; with key, the key's value and revision;
// with values, the values whole by key in pages; with key and history, the values it keeps for the key with their times.
func OwnerRead(ask func(key string, body any) (json.RawMessage, error), tool string) StateRead {
entry := func(key string) (json.RawMessage, uint64, error) {
raw, err := ask(tool, map[string]any{"key": key})
if err != nil {
return nil, 0, fmt.Errorf("%s: %w", tool, err)
}
var a struct {
Found bool `json:"found"`
Value json.RawMessage `json:"value"`
Revision uint64 `json:"revision"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, 0, fmt.Errorf("%s answered what is not a key's value: %w", tool, err)
}
if !a.Found || len(a.Value) == 0 {
return nil, 0, nil
}
return a.Value, a.Revision, nil
}
return StateRead{
Keys: func() ([]string, error) {
raw, err := ask(tool, map[string]any{})
if err != nil {
return nil, fmt.Errorf("%s: %w", tool, err)
}
var a struct {
Keys []string `json:"keys"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("%s answered what is not a state's keys: %w", tool, err)
}
return a.Keys, nil
},
GetEntry: entry,
Get: func(key string) (json.RawMessage, error) {
v, _, err := entry(key)
return v, err
},
All: func() (map[string]json.RawMessage, error) {
out := map[string]json.RawMessage{}
for after, pages := "", 0; ; pages++ {
if pages == maxOwnerPages {
return nil, fmt.Errorf("%s answered more than %d pages", tool, maxOwnerPages)
}
body := map[string]any{"values": true}
if after != "" {
body["after"] = after
}
raw, err := ask(tool, body)
if err != nil {
return nil, fmt.Errorf("%s: %w", tool, err)
}
var a struct {
Values map[string]json.RawMessage `json:"values"`
Next string `json:"next"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("%s answered what is not a page of values: %w", tool, err)
}
for k, v := range a.Values {
out[k] = v
}
if a.Next == "" || a.Next == after {
return out, nil
}
after = a.Next
}
},
History: func(key string) ([]Revision, error) {
raw, err := ask(tool, map[string]any{"key": key, "history": true})
if err != nil {
return nil, fmt.Errorf("%s: %w", tool, err)
}
var a struct {
History *[]struct {
Revision uint64 `json:"revision"`
At time.Time `json:"at"`
Op string `json:"op"`
Value json.RawMessage `json:"value"`
} `json:"history"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("%s answered what is not a key's history: %w", tool, err)
}
if a.History == nil {
return nil, fmt.Errorf("%s answers no history of a key: its owner's read tool takes no history argument yet", tool)
}
out := make([]Revision, 0, len(*a.History))
for _, h := range *a.History {
out = append(out, Revision{Revision: h.Revision, At: h.At, Op: h.Op, Value: h.Value})
}
return out, nil
},
}
}
// maxOwnerPages bounds one read of a state's values page by page (a page is at most 768 KiB, mesh-sdk views PageBytes).
const maxOwnerPages = 256
@@ -33,6 +33,9 @@ type StateRead struct {
GetEntry func(key string) (json.RawMessage, uint64, error)
// History, when set, gives every value the state keeps for a key with its time (indexer_module_history, indexer_heard).
History func(key string) ([]Revision, error)
// All, when set, gives every value by key in one read (an owner's read tool's pages, OwnerRead), so a read of many keys
// is not a call per key.
All func() (map[string]json.RawMessage, error)
}
// Reader answers the read tools from a modules state.
@@ -579,6 +582,16 @@ func parallel(n int, read func(i int) error) error {
// values reads the stored value of each key, in parallel, in the order of keys (nil for a key with none).
func (s StateRead) values(keys []string) ([]json.RawMessage, error) {
out := make([]json.RawMessage, len(keys))
if s.All != nil {
all, err := s.All()
if err != nil {
return nil, err
}
for i, k := range keys {
out[i] = all[k]
}
return out, nil
}
err := parallel(len(keys), func(i int) error {
raw, err := s.Get(keys[i])
out[i] = raw
@@ -81,6 +81,9 @@ type StatusMoving struct {
}
func values(s StateRead) (map[string]json.RawMessage, error) {
if s.All != nil {
return s.All()
}
if s.Keys == nil || s.Get == nil {
return nil, fmt.Errorf("not read here")
}
+19 -4
View File
@@ -51,6 +51,8 @@ type manifest struct {
Version string `json:"version"`
Slug string `json:"slug"`
Reads []string `json:"reads"`
Consumes []string `json:"consumes"`
Invokes []string `json:"invokes"`
Uses []string `json:"uses"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
@@ -87,17 +89,29 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
if m.Module != "mesh-delivery-view" {
t.Errorf("module = %q", m.Module)
}
want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.placement", "mesh-delivery-assigner.wants", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", "mesh-delivery-indexer.graph", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.packages", "mesh-delivery-indexer.predefined", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-engine.have"} // view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9
want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-engine.have"} // no reads of the indexer's or the assigner's states (ADR 0615 §1, DLV-M4); view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9
got := slices.Clone(m.Reads)
slices.Sort(got)
if !reflect.DeepEqual(got, want) {
t.Errorf("reads = %v, want exactly %v", got, want)
}
for _, k := range []string{"consumes", "provides", "claims"} {
for _, k := range []string{"provides", "claims"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the view writes only its answers", k)
}
}
// The indexer's and the assigner's states are asked of their owners' read tools (design 25 § 12.2), the six the process
// follows then heard on their views families (ADR 0615 §1, §5; DLV-M4).
if want := []string{"mesh-delivery-assigner.assigner_read_asks", "mesh-delivery-assigner.assigner_read_differences", "mesh-delivery-assigner.assigner_read_placement",
"mesh-delivery-assigner.assigner_read_wants", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_config_candidates",
"mesh-delivery-indexer.indexer_read_graph", "mesh-delivery-indexer.indexer_read_heard", "mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken"}; !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
if want := []string{"mesh-delivery-assigner.views.asks", "mesh-delivery-assigner.views.placement", "mesh-delivery-assigner.views.wants",
"mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) {
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
// The one event the view says: a source revision is delivered (1906/D9, ADR 0539 §17), derived by it from the wants and the haves.
if !reflect.DeepEqual(m.Emits, []string{"delivered", "moving", "settled", "stuck"}) {
t.Errorf("emits = %v, want exactly delivered, moving, settled and stuck (1906/D9, D10)", m.Emits)
@@ -108,7 +122,8 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
}
// The states the view writes: the operator's answers to the asks that need no proof (ADR 0528 clause 6, 1297/B9), which the
// teller reads, and how far each repository reached (ADR 0556 clause 2, issue 1280), which the gate reads.
if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}}; !reflect.DeepEqual(m.State, want) {
// Its first views of the owners' states and the finding of one unanswered past its bound (ADR 0615 §6).
if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) {
t.Errorf("state = %+v, want %+v", m.State, want)
}
if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 {
@@ -119,7 +134,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
}
// The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer
// itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads.
if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers"}; !reflect.DeepEqual(m.Tools, want) {
if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
for _, a := range m.Build.Artifacts {
+28 -14
View File
@@ -24,7 +24,9 @@
"view_reach",
"indexer_module_history",
"indexer_heard",
"view_tiers"
"view_tiers",
"view_first_views",
"view_first_view_again"
],
"emits": [
"delivered",
@@ -34,7 +36,31 @@
],
"state": [
{ "name": "answers", "history": 5 },
{ "name": "reach", "history": 1 }
{ "name": "reach", "history": 1 },
{ "name": "first-views", "history": 1 },
{ "name": "findings", "history": 1 }
],
"consumes": [
"mesh-delivery-assigner.views.asks",
"mesh-delivery-assigner.views.placement",
"mesh-delivery-assigner.views.wants",
"mesh-delivery-indexer.views.candidates",
"mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.taken"
],
"invokes": [
"mesh-delivery-assigner.assigner_read_asks",
"mesh-delivery-assigner.assigner_read_differences",
"mesh-delivery-assigner.assigner_read_placement",
"mesh-delivery-assigner.assigner_read_wants",
"mesh-delivery-indexer.indexer_read_candidates",
"mesh-delivery-indexer.indexer_read_config_candidates",
"mesh-delivery-indexer.indexer_read_graph",
"mesh-delivery-indexer.indexer_read_heard",
"mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_packages",
"mesh-delivery-indexer.indexer_read_predefined",
"mesh-delivery-indexer.indexer_read_taken"
],
"reads": [
"mesh-build-go.pauses",
@@ -44,23 +70,11 @@
"mesh-build-node.pauses",
"mesh-build-node.steps",
"mesh-config.resolved",
"mesh-delivery-assigner.asks",
"mesh-delivery-assigner.differences",
"mesh-delivery-assigner.placement",
"mesh-delivery-assigner.wants",
"mesh-delivery-builder.build-log",
"mesh-delivery-builder.builds",
"mesh-delivery-checker.tries",
"mesh-delivery-gate.asks",
"mesh-delivery-gate.source",
"mesh-delivery-indexer.candidates",
"mesh-delivery-indexer.config-candidates",
"mesh-delivery-indexer.graph",
"mesh-delivery-indexer.heard",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.packages",
"mesh-delivery-indexer.predefined",
"mesh-delivery-indexer.taken",
"mesh-delivery-teller.answers",
"mesh-conditions.conditions",
"mesh-healers.attempts",
@@ -10,8 +10,10 @@ import (
"os"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-watchman/internal/socket"
"mesh-delivery-watchman/internal/watch"
@@ -28,18 +30,22 @@ const (
VerbDeadLetters = "dead-letters"
VerbResync = "resync"
ToolFindings = "watchman_findings"
// ToolFirstViews and ToolFirstViewAgain are where the watchman's first views of the assigner's wants and the indexer's
// modules stand, and asking one again by hand (novox/hq ADR 0615, DLV-M5).
ToolFirstViews = "watchman_first_views"
ToolFirstViewAgain = "watchman_first_view_again"
)
// Verbs are the seat's verbs, in the manifest's order.
var Verbs = []string{VerbSignals, VerbStuck, VerbPresence, VerbDeadLetters, VerbResync}
// ModuleTools are the module's own tools, in the manifest's order.
var ModuleTools = []string{ToolFindings}
var ModuleTools = []string{ToolFindings, ToolFirstViews, ToolFirstViewAgain}
// Reads names, per tool, the states it reads whole; Acts, per tool, the program's act it starts. The clause-12 test holds
// that every state the manifest keeps is read by one, and every act the program takes by hand is started by one.
var (
Reads = map[string][]string{VerbSignals: {"pulse"}, VerbStuck: {"stuck"}, VerbPresence: {"presence"}, ToolFindings: {"findings"}}
Reads = map[string][]string{VerbSignals: {"pulse"}, VerbStuck: {"stuck"}, VerbPresence: {"presence"}, ToolFindings: {"findings"}, ToolFirstViews: {views.StatusState}}
Acts = map[string][]string{VerbDeadLetters: {socket.AskDeadLetters}, VerbResync: {socket.AskResync}}
)
@@ -124,7 +130,7 @@ func Tools(states func(string) state, ask func(socket.Ask) (json.RawMessage, err
}
resync := func(map[string]any) (any, error) { return ask(socket.Ask{Ask: socket.AskResync}) }
verb := func(v string) string { return Seat + "." + v }
return []stdio.Tool{
return append([]stdio.Tool{
{Name: verb(VerbSignals), Input: keyArgs, Run: signals,
Description: "The signals table's rows the watchman holds (S1, S8, S9, S10, S11, S21 and the haves' health), each a pulse key " +
"whose time to live is its bound and whose expiry is its finding (novox/hq ADR 0564 §4), and every pulse the watchman " +
@@ -157,7 +163,7 @@ func Tools(states func(string) state, ask func(socket.Ask) (json.RawMessage, err
Description: "The watchman's findings (novox/hq ADR 0564 §2): each thing it observes wrong now, <scope>.<id>.<kind>, with its " +
"severity, subject, evidence and the row that observed it; the condition keeper raises and clears the conditions from " +
"them. Give key for one, prefix to narrow. Read-only."},
}
}, views.StatusTools(watch.Observer, ToolFirstViews, ToolFirstViewAgain, stdio.State(views.StatusState), time.Now)...)
}
func main() {
@@ -17,6 +17,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/sdk/firstview/firstviewtest"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -57,6 +58,14 @@ accounts {
}
`
// serveOwners serves the assigner's wants and the indexer's modules as their owners do, by their read tools and views
// families (DLV-M5, ADR 0615): the watchman (n1) follows both, never their buckets.
func serveOwners(t *testing.T, ctx context.Context, js jetstream.JetStream, nc *nats.Conn) {
t.Helper()
firstviewtest.Serve(t, ctx, js, nc, delivery.AssignerModule, wantsState, "n1_"+delivery.WatchmanModule)
firstviewtest.Serve(t, ctx, js, nc, delivery.IndexerModule, modulesState, "n1_"+delivery.WatchmanModule)
}
// TestWatchmanWhole starts the program the way the node does (its entry, run, with its environment and a broker file)
// against an embedded bus with the system exports and JetStream, and proves the four things the issue names.
func TestWatchmanWhole(t *testing.T) { // 1135/AC8
@@ -109,6 +118,7 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
{Bucket: delivery.AssignerWantsBucket, History: 20},
{Bucket: delivery.HaveBucket, History: 20},
{Bucket: watch.FindingsBucket, History: 5},
{Bucket: firstViewsBucket, History: 1},
} {
kv, err := js.CreateKeyValue(ctx, c)
if err != nil {
@@ -116,6 +126,7 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
}
kvs[c.Bucket] = kv
}
serveOwners(t, ctx, js, ctl)
// The controller's consumer on DEAD_LETTER_NOTICES, the dead letters' streams and a source stream with one message a
// consumer will give up on (1952/WM5); no lease bucket at all (issue 1126).
@@ -355,6 +366,7 @@ func TestAnAskRunningBeforeTheStartGetsItsPulse(t *testing.T) { // 1909
{Bucket: delivery.AssignerWantsBucket, History: 20},
{Bucket: delivery.HaveBucket, History: 20},
{Bucket: watch.FindingsBucket, History: 5},
{Bucket: firstViewsBucket, History: 1},
} {
kv, err := js.CreateKeyValue(ctx, c)
if err != nil {
@@ -362,6 +374,7 @@ func TestAnAskRunningBeforeTheStartGetsItsPulse(t *testing.T) { // 1909
}
kvs[c.Bucket] = kv
}
serveOwners(t, ctx, js, ctl)
const id = "m0000009ffffff"
body, err := delivery.EncodeStep(delivery.Step{ID: id, Tool: "build_go_test", Node: "n1", State: delivery.StepRunning, Started: time.Now().UTC()})
if err != nil {
@@ -440,6 +453,7 @@ func TestAWantIsMirroredAsAPulseAndTheHaveNamingItEndsIt(t *testing.T) {
{Bucket: delivery.AssignerWantsBucket, History: 20},
{Bucket: delivery.HaveBucket, History: 20},
{Bucket: watch.FindingsBucket, History: 5},
{Bucket: firstViewsBucket, History: 1},
} {
kv, err := js.CreateKeyValue(ctx, c)
if err != nil {
@@ -447,6 +461,7 @@ func TestAWantIsMirroredAsAPulseAndTheHaveNamingItEndsIt(t *testing.T) {
}
kvs[c.Bucket] = kv
}
serveOwners(t, ctx, js, ctl)
fpNew := delivery.SourcePrefix2 + strings.Repeat("b", 64)
fpOld := delivery.SourcePrefix2 + strings.Repeat("a", 64)
putWant := func(node, module, fp string) {
@@ -18,6 +18,9 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/firstview"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -122,6 +125,26 @@ func settle(ctx context.Context, kv jetstream.KeyValue, each func(jetstream.KeyV
}
}
// readLate reads an owner's state the watchman follows by its first view (pic), on a goroutine of its own once that is
// answered, so an owner that is down never holds the start: every key it holds now, then the start's end for its bucket
// (watch.ReadLate), then each change until ctx ends.
func readLate(ctx context.Context, w *watch.Watchman, pic *firstview.Picture, bucket string, each func(jetstream.KeyValueEntry)) error {
select {
case <-ctx.Done():
return nil
case <-pic.Follower().Ready():
}
sw, err := settle(ctx, pic, each)
if err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
w.ReadLate(bucket)
return follow(ctx, sw, each)
}
// follow hands each change of a watcher to each until ctx ends; a watch that ends is an error, and the process
// manager starts the program again.
func follow(ctx context.Context, w jetstream.KeyWatcher, each func(jetstream.KeyValueEntry)) error {
@@ -147,6 +170,26 @@ func stepTaken(w *watch.Watchman, language string, e jetstream.KeyValueEntry) {
w.Step(language, e.Key(), e.Value(), opOf(e))
}
// createdOr is when an entry was written, or at when its picture does not say. mesh-sdk's views.Picture (the join's J3)
// names each first-view key's written time as its Created, and the tool runner's state reads carry it (J2), so a want
// that stood before the start waits from when it was written; at is left only for an owner whose answer names no time.
func createdOr(e jetstream.KeyValueEntry, at time.Time) time.Time {
if t := e.Created(); !t.IsZero() {
return t
}
return at
}
// The watchman's own states of its first views (views.StatusState); its findings of a first view unanswered past its
// bound go to its findings bucket with the others (ADR 0615 §6). The owners' states it follows are the assigner's wants
// and the indexer's modules (DLV-M5).
var firstViewsBucket = delivery.WatchmanModule + "_" + views.StatusState
const (
wantsState = "wants"
modulesState = "modules"
)
// run is the program: it returns when ctx ends or the bus cannot be used.
func run(ctx context.Context, getenv func(string) string, logOut io.Writer) error {
logger := log.New(logOut, "mesh-delivery-watchman: ", log.LstdFlags)
@@ -168,9 +211,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
defer conn.Close()
// Its own presence, pulses and stuck rows are what it keeps: each stops the start, naming its grant. The assigner's wants
// and the node-engine's haves only make the pulses of a move: without one the watchman watches presence and the build
// seats' steps, and says that moves are not watched (issue 1945).
var kvs struct{ presence, pulse, stuck, findings, wants, haves jetstream.KeyValue }
// and the node-engine's haves only make the pulses of a move: without the haves the watchman watches presence and the
// build seats' steps, and says that moves are not watched (issue 1945).
var kvs struct{ presence, pulse, stuck, findings, haves jetstream.KeyValue }
for _, b := range []struct {
name string
into *jetstream.KeyValue
@@ -179,14 +222,26 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
}
}
kvs.wants = process.OptionalBucket(ctx, js, delivery.AssignerWantsBucket, "a want makes no pulse, so a move that stops is not seen, until the watchman starts again", logger.Printf)
kvs.haves = process.OptionalBucket(ctx, js, delivery.HaveBucket, "a want makes no pulse, so a move that stops is not seen, until the watchman starts again", logger.Printf)
if kvs.wants == nil || kvs.haves == nil {
kvs.wants, kvs.haves = nil, nil // a want is a pulse only against the have that ends it
// The assigner's wants and the indexer's modules are their owners' states, read by the first view of their read tools and
// then their views families, never their buckets (ADR 0615 §1, DLV-M5). An owner that is down never holds the start: each
// is read on a goroutine once its first view is answered, and until then the unanswered first view is the watchman's
// finding.
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
if err != nil {
return fmt.Errorf("the watchman's connection for its tool calls: %w", err)
}
defer calls.Close()
reader := &firstview.Reader{Module: delivery.WatchmanModule, Call: calls.Call, Logf: logger.Printf,
Findings: func() jetstream.KeyValue { return kvs.findings }}
wants := reader.Follow(delivery.AssignerModule, wantsState)
modules := reader.Follow(delivery.IndexerModule, modulesState)
w := watch.New(&kvOut{ctx: ctx, presence: kvs.presence, pulse: kvs.pulse, stuk: kvs.stuck, findings: kvs.findings}, time.Now)
w.Logf(logger.Printf)
// What the wants and the modules hold is ended by their own late read (watch.ReadLate), never by the start's end.
w.Awaits(delivery.AssignerWantsBucket)
w.Awaits(delivery.ModulesBucket)
// A test shortens how long a move may take; the mesh never sets it.
if v := strings.TrimSpace(getenv("MESH_DELIVERY_PULSE_BOUND")); v != "" {
d, err := time.ParseDuration(v)
@@ -232,18 +287,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err != nil {
return err
}
// What the node agents run, then the assigner's wants (issue 1906, ADR 0539 §19): a want is a pulse owed by its node's agent
// until the have names its fingerprint, so the haves are read first and a want already applied makes no pulse.
var haveSeen, wantSeen jetstream.KeyWatcher
// What the node agents run (issue 1906, ADR 0539 §19): a want is a pulse owed by its node's agent until the have names its
// fingerprint, so the haves are read at the start and the wants after them, once their first view is answered.
var haveSeen jetstream.KeyWatcher
if kvs.haves != nil {
if haveSeen, err = settle(ctx, kvs.haves, func(e jetstream.KeyValueEntry) { w.Have(e.Key(), e.Value(), opOf(e)) }); err != nil {
process.NotWatched(logger.Printf, delivery.HaveBucket, err, "moves are not watched until the watchman starts again")
} else if wantSeen, err = settle(ctx, kvs.wants, func(e jetstream.KeyValueEntry) { w.Want(e.Key(), e.Value(), opOf(e), e.Created()) }); err != nil {
process.NotWatched(logger.Printf, delivery.AssignerWantsBucket, err, "moves are not watched until the watchman starts again")
haveSeen.Stop()
haveSeen = nil
} else {
w.ReadWhole(delivery.AssignerWantsBucket)
}
}
// The builder's builds (ADR 0603 §8): a key queued behind a package is a wait, mirrored from the time it was written.
@@ -358,17 +408,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
go func() { _ = follow(ctx, hw, healthEach) }()
})
}
// The indexer's modules (ADR 0578 §1): a module whose value carries a refusal of its manifest is a finding; read
// before the findings are reconciled, each change heard on the watch.
verdictEach := func(e jetstream.KeyValueEntry) { w.ModuleVerdict(e.Key(), e.Value(), opOf(e)) }
var verdictWatch jetstream.KeyWatcher
if kv, err := js.KeyValue(ctx, delivery.ModulesBucket); err == nil {
if verdictWatch, err = settle(ctx, kv, verdictEach); err != nil {
process.NotWatched(logger.Printf, delivery.ModulesBucket, err, "no refused manifest is said until the watchman starts again")
}
} else {
process.NotWatched(logger.Printf, delivery.ModulesBucket, err, "no refused manifest is said until the watchman starts again")
}
dead := &deadKeeper{ctx: ctx, js: js, w: w, logf: logger.Printf}
w.SetRecount(dead.count)
dead.count()
@@ -384,8 +423,20 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if healthWatch != nil {
go func() { errs <- follow(ctx, healthWatch, healthEach) }()
}
if verdictWatch != nil {
go func() { errs <- follow(ctx, verdictWatch, verdictEach) }()
// The first views of the wants and the modules, asked now and read each on its own goroutine once answered (ADR 0615).
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "watchman_first_views reads nothing and watchman_first_view_again is not heard", logger.Printf))
// The indexer's modules (ADR 0578 §1): a module whose value carries a refusal of its manifest is a finding.
verdictEach := func(e jetstream.KeyValueEntry) { w.ModuleVerdict(e.Key(), e.Value(), opOf(e)) }
go func() { errs <- readLate(ctx, w, modules, delivery.ModulesBucket, verdictEach) }()
if haveSeen != nil {
go func() {
errs <- follow(ctx, haveSeen, func(e jetstream.KeyValueEntry) { w.Have(e.Key(), e.Value(), opOf(e)) })
}()
go func() {
errs <- readLate(ctx, w, wants, delivery.AssignerWantsBucket, func(e jetstream.KeyValueEntry) {
w.Want(e.Key(), e.Value(), opOf(e), createdOr(e, time.Now()))
})
}()
}
if provWatch != nil {
go func() {
@@ -402,14 +453,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
go func() {
errs <- follow(ctx, pulses, func(e jetstream.KeyValueEntry) { w.Pulse(e.Key(), opOf(e), e.Created()) })
}()
if haveSeen != nil && wantSeen != nil {
go func() {
errs <- follow(ctx, haveSeen, func(e jetstream.KeyValueEntry) { w.Have(e.Key(), e.Value(), opOf(e)) })
}()
go func() {
errs <- follow(ctx, wantSeen, func(e jetstream.KeyValueEntry) { w.Want(e.Key(), e.Value(), opOf(e), e.Created()) })
}()
}
if buildSeen != nil {
go func() {
errs <- follow(ctx, buildSeen, func(e jetstream.KeyValueEntry) { w.Build(e.Key(), e.Value(), opOf(e), e.Created()) })
+4
View File
@@ -22,3 +22,7 @@ require (
)
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ../mesh-delivery-builder/buildrun
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
@@ -101,6 +101,10 @@ type Watchman struct {
standing map[string]map[string]bool
readWhole map[string]bool
reconciled bool
// awaited is each bucket read only once its owner's first view is answered, after the start's end (Awaits): its seeded
// pulses, stuck keys and findings wait for its own end (ReadLate), so a restart while its owner is down neither drops
// nor raises again what it holds.
awaited map[string]bool
}
// New is a watchman writing to out, reading the time from now.
@@ -111,7 +115,7 @@ func New(out Out, now func() time.Time) *Watchman {
haveState: map[string]delivery.Have{}, unhealthy: map[string]int{}, advised: map[string]int{}, advisedSince: map[string]time.Time{},
control: map[string]bool{}, failedSince: map[string]time.Time{}, provFailed: map[string]map[string]ProvReport{}, closures: map[string]map[string]time.Time{}, waiting: map[string]map[string]string{},
health: map[string]delivery.NodeHealth{}, tunnels: map[string]string{},
standing: map[string]map[string]bool{}, readWhole: map[string]bool{}}
standing: map[string]map[string]bool{}, readWhole: map[string]bool{}, awaited: map[string]bool{}}
}
// Logf sets where a write that failed is said; it is silent until set.
@@ -294,7 +298,7 @@ func (w *Watchman) Reconcile() {
w.mu.Lock()
defer w.mu.Unlock()
for pk, p := range w.pulses {
if p.seeded {
if p.seeded && !w.awaited[p.bucket] {
w.deletePulse(pk)
}
}
@@ -319,7 +323,7 @@ func (w *Watchman) ReadWhole(bucket string) {
// stands records a mirrored key read standing during the start.
func (w *Watchman) stands(bucket, key string) {
if w.reconciled {
if w.reconciled && !w.awaited[bucket] {
return
}
if w.standing[bucket] == nil {
@@ -330,15 +334,61 @@ func (w *Watchman) stands(bucket, key string) {
func (w *Watchman) closeGone() {
for sk, s := range w.stuck {
if s.Moved || !w.readWhole[s.Bucket] || w.standing[s.Bucket][s.Key] {
if s.Moved || w.awaited[s.Bucket] || !w.readWhole[s.Bucket] || w.standing[s.Bucket][s.Key] {
continue
}
s.Moved = true
w.say("the stuck key %s is closed: its key %s is no longer in %s (it aged out of the bucket or was deleted while nobody heard), so nothing waits for it since %s",
sk, s.Key, s.Bucket, s.Since.UTC().Format(time.RFC3339))
w.putStuck(sk, s)
w.closeStuck(sk, s)
}
w.standing, w.readWhole, w.reconciled = map[string]map[string]bool{}, map[string]bool{}, true
standing := map[string]map[string]bool{}
for b := range w.awaited {
standing[b] = w.standing[b]
}
w.standing, w.readWhole, w.reconciled = standing, map[string]bool{}, true
}
func (w *Watchman) closeStuck(sk string, s delivery.Stuck) {
s.Moved = true
w.say("the stuck key %s is closed: its key %s is no longer in %s (it aged out of the bucket or was deleted while nobody heard), so nothing waits for it since %s",
sk, s.Key, s.Bucket, s.Since.UTC().Format(time.RFC3339))
w.putStuck(sk, s)
}
// Awaits says bucket (an owner's state the watchman reads through its first view: the assigner's wants, the indexer's
// modules; novox/hq ADR 0615) is read only once that first view is answered, which may be after the start's end: Reconcile
// leaves its seeded pulses, its stuck keys and the seeded findings it is the source of to ReadLate. Called before Reconcile.
func (w *Watchman) Awaits(bucket string) {
w.mu.Lock()
defer w.mu.Unlock()
w.awaited[bucket] = true
}
// ReadLate ends the start for an awaited bucket, called once every key it holds was read standing: as Reconcile does for
// the others, a seeded pulse of it no key made again is deleted, a stuck key of it whose key is gone is closed, and a
// seeded finding it is the source of and nothing observed again is deleted.
func (w *Watchman) ReadLate(bucket string) {
w.mu.Lock()
defer w.mu.Unlock()
if !w.awaited[bucket] {
return
}
for pk, p := range w.pulses {
if p.seeded && p.bucket == bucket {
w.deletePulse(pk)
}
}
for sk, s := range w.stuck {
if !s.Moved && s.Bucket == bucket && !w.standing[bucket][s.Key] {
w.closeStuck(sk, s)
}
}
for key := range w.seededFindings {
if f, err := DecodeFinding(w.findings[key]); err == nil && f.Source == bucket {
delete(w.seededFindings, key)
w.unfind(key)
}
}
delete(w.awaited, bucket)
delete(w.standing, bucket)
}
// reconcileFindings ends the start for the findings: one read at the start that what the watchman read since did not
@@ -350,6 +400,9 @@ func (w *Watchman) reconcileFindings() {
kept[due.Finding.Key()] = true
}
for key := range w.seededFindings {
if f, err := DecodeFinding(w.findings[key]); err == nil && w.awaited[f.Source] {
continue // ended by ReadLate
}
delete(w.seededFindings, key)
if kept[key] || signalKind(w.findings[key]) {
continue
@@ -668,12 +668,17 @@ func TestAStuckKeyWhoseKeyIsGoneIsClosedAtTheStart(t *testing.T) {
standing func(w *Watchman) // what the start reads standing
readWhole bool
closed bool
late bool // read after the start's end (Awaits, ReadLate)
}{
{"an ask's step aged out of the steps bucket", checksKey, "askold1", func(*Watchman) {}, true, true},
{"an ask's step still running", checksKey, "askold1", func(w *Watchman) { w.Step("go", "n1.askold1", runningStep("askold1", t0), Put) }, true, false},
{"the steps bucket could not be read", checksKey, "askold1", func(*Watchman) {}, false, false},
{"a want deleted while nobody heard", delivery.AssignerWantsBucket, wantKey, func(*Watchman) {}, true, true},
{"a want still standing", delivery.AssignerWantsBucket, wantKey, func(w *Watchman) { w.Want(wantKey, wantValue(t, fpNew, false), Put, t0) }, true, false},
{"an ask's step aged out of the steps bucket", checksKey, "askold1", func(*Watchman) {}, true, true, false},
{"an ask's step still running", checksKey, "askold1", func(w *Watchman) { w.Step("go", "n1.askold1", runningStep("askold1", t0), Put) }, true, false, false},
{"the steps bucket could not be read", checksKey, "askold1", func(*Watchman) {}, false, false, false},
{"a want deleted while nobody heard", delivery.AssignerWantsBucket, wantKey, func(*Watchman) {}, true, true, false},
{"a want still standing", delivery.AssignerWantsBucket, wantKey, func(w *Watchman) { w.Want(wantKey, wantValue(t, fpNew, false), Put, t0) }, true, false, false},
// DLV-M5 (ADR 0615): the wants are read from the assigner's first view, which may come after the start's end; what
// they hold waits for it, and is then reconciled as at the start.
{"a want deleted while nobody heard, read from a late first view", delivery.AssignerWantsBucket, wantKey, func(*Watchman) {}, true, true, true},
{"a want still standing, read from a late first view", delivery.AssignerWantsBucket, wantKey, func(w *Watchman) { w.Want(wantKey, wantValue(t, fpNew, false), Put, t0) }, true, false, true},
} {
t.Run(row.name, func(t *testing.T) {
w, o, c := setup()
@@ -684,11 +689,36 @@ func TestAStuckKeyWhoseKeyIsGoneIsClosedAtTheStart(t *testing.T) {
if _, ok := o.findings[FindingKey(ScopeDelivery, sk, KindStuck)]; !ok {
t.Fatal("the seeded stuck key raised no finding")
}
row.standing(w)
if row.readWhole {
w.ReadWhole(row.bucket)
if row.late {
// A pulse of another key of the bucket, and a finding the bucket is the source of, stood at the start.
pk := pulseKeyOf(t, row.bucket, "want.n9.gone")
w.SeedPulse(pk)
fv, err := EncodeFinding(Finding{Scope: ScopeModule, ID: "gone", Kind: KindManifestRefused, Severity: Warning, Source: row.bucket, Summary: "refused", Since: t0})
if err != nil {
t.Fatal(err)
}
fk := FindingKey(ScopeModule, "gone", KindManifestRefused)
o.findings[fk], _ = DecodeFinding(fv)
w.SeedFinding(fk, fv)
w.Awaits(row.bucket)
w.Reconcile()
_, open := o.findings[FindingKey(ScopeDelivery, sk, KindStuck)]
_, seeded := o.findings[fk]
if o.stuck[sk].Moved || !open || !seeded || slices.Contains(o.pulseDeletes, pk) {
t.Fatalf("the start's end took what an awaited bucket holds: moved %v, stuck open %v, finding %v, pulses deleted %v", o.stuck[sk].Moved, open, seeded, o.pulseDeletes)
}
row.standing(w)
w.ReadLate(row.bucket)
if _, seeded := o.findings[fk]; seeded || !slices.Contains(o.pulseDeletes, pk) {
t.Errorf("the late read kept the finding (%v) or the pulse (deleted %v) nothing made again", seeded, o.pulseDeletes)
}
} else {
row.standing(w)
if row.readWhole {
w.ReadWhole(row.bucket)
}
w.Reconcile()
}
w.Reconcile()
_, open := o.findings[FindingKey(ScopeDelivery, sk, KindStuck)]
if got := o.stuck[sk].Moved; got != row.closed || open == row.closed {
t.Errorf("moved %v, finding open %v; want closed %v", got, open, row.closed)
@@ -51,6 +51,8 @@ type manifest struct {
Version string `json:"version"`
Slug string `json:"slug"`
Reads []string `json:"reads"`
Invokes []string `json:"invokes"`
Consumes []string `json:"consumes"`
State []state `json:"state"`
OwnSecrets map[string]string `json:"own-secrets"`
SecretsOwner string `json:"secrets-owner"`
@@ -84,24 +86,37 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6
if m.Module != "mesh-delivery-watchman" || m.Moved || m.Slug != "watchman" {
t.Errorf("module %q moved %v (it has no template, so it is not moved) slug %q", m.Module, m.Moved, m.Slug)
}
want := []state{{Name: "presence", History: 20}, {Name: "pulse", KeyTTL: true}, {Name: "stuck", History: 20}, {Name: "findings", History: 5}}
want := []state{{Name: "presence", History: 20}, {Name: "pulse", KeyTTL: true}, {Name: "stuck", History: 20}, {Name: "findings", History: 5}, {Name: "first-views", History: 1}}
if !reflect.DeepEqual(m.State, want) {
t.Errorf("state = %+v, want %+v", m.State, want)
}
reads := slices.Clone(m.Reads)
slices.Sort(reads)
if !reflect.DeepEqual(reads, []string{"mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps",
"mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-indexer.modules", "mesh-host.health", "mesh-provisioner.provisions", "node-engine.have"}) {
"mesh-delivery-builder.builds", "mesh-host.health", "mesh-provisioner.provisions", "node-engine.have"}) {
t.Errorf("reads = %v", reads)
}
// DLV-M5 (ADR 0615 §1): the assigner's wants and the indexer's modules are their owners' states, asked by their read
// tools and followed by their views families, never read.
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)
}
}
if want := []string{"mesh-delivery-assigner.assigner_read_wants", "mesh-delivery-indexer.indexer_read_modules"}; !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
if want := []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.modules"}; !reflect.DeepEqual(m.Consumes, want) {
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
// 1952/WM1: the watchman holds the seat mesh-watchdogs at mesh scope, its first seat (novox/hq ADR 0564 §1, §4).
if !reflect.DeepEqual(m.Claims, []claim{{Name: "mesh-watchdogs", Scope: "mesh", Serves: []string{"signals", "stuck", "presence", "dead-letters", "resync"}}}) {
t.Errorf("claims = %+v", m.Claims)
}
if !reflect.DeepEqual(m.Tools, []string{"watchman_findings"}) {
if !reflect.DeepEqual(m.Tools, []string{"watchman_findings", "watchman_first_views", "watchman_first_view_again"}) {
t.Errorf("tools = %v", m.Tools)
}
for _, k := range []string{"emits", "context", "items", "consumes", "provides"} {
for _, k := range []string{"emits", "context", "items", "provides"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the watchman has none", k)
}
+13 -4
View File
@@ -19,21 +19,30 @@
"mesh-build-go.steps",
"mesh-build-image.steps",
"mesh-build-node.steps",
"mesh-delivery-assigner.wants",
"mesh-delivery-builder.builds",
"mesh-delivery-indexer.modules",
"mesh-host.health",
"mesh-provisioner.provisions",
"node-engine.have"
],
"invokes": [
"mesh-delivery-assigner.assigner_read_wants",
"mesh-delivery-indexer.indexer_read_modules"
],
"consumes": [
"mesh-delivery-assigner.views.wants",
"mesh-delivery-indexer.views.modules"
],
"tools": [
"watchman_findings"
"watchman_findings",
"watchman_first_views",
"watchman_first_view_again"
],
"state": [
{ "name": "presence", "history": 20 },
{ "name": "pulse", "key-ttl": true },
{ "name": "stuck", "history": 20 },
{ "name": "findings", "history": 5 }
{ "name": "findings", "history": 5 },
{ "name": "first-views", "history": 1 }
],
"own-secrets": {
"broker": "${dir:state}/broker"
@@ -20,6 +20,7 @@ func TestIssueTokensAndTheManifest(t *testing.T) {
{"S54/AC1 tokens answers each stage and relays the sealed token of one node", tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode},
{"S54/AC2 the manifest says what the code does", theManifestSaysWhatTheCodeDoes},
{"S56 the bus node's loaded list is read from the grants part's pass", theLoadedListIsReadFromTheGrantsPass},
{"DLV-M6 the placement is read from its first view, never from the assigner's state", thePlacementIsReadFromItsFirstView},
} {
t.Run(c.name, c.run)
}
@@ -59,3 +60,30 @@ func theLoadedListIsReadFromTheGrantsPass(t *testing.T) {
t.Error("no pass composed read as an answer")
}
}
// thePlacementIsReadFromItsFirstView: the holder's placement comes from the picture of the assigner's placement (its first
// view and views family, ADR 0615 §1), every other state through the runtime; the runtime is never asked for placement.
func thePlacementIsReadFromItsFirstView(t *testing.T) {
pics := memPictures{PlacementRead: {"joiner.node": `{"v":1,"node":"joiner","decided":{"address":"10.9.0.11"},"modules":[]}`}}
rest := memRead{PlacementRead: {"stale.node": `{"v":1,"node":"stale","modules":[]}`}, "node-engine.have": {}}
h := &Holder{Read: followedReader{pics: pics, rest: rest}}
p, err := h.placements()
if err != nil {
t.Fatal(err)
}
if _, ok := p["joiner"]; !ok || len(p) != 1 {
t.Errorf("placements = %v, want joiner's alone, from the first view", p)
}
if keys, err := h.Read.Keys("node-engine.have"); err != nil || len(keys) != 0 {
t.Errorf("node-engine.have read as %v, %v: want it through the runtime", keys, err)
}
}
// memPictures is the followed states (views.Reader), `<owner>.<state>` then key.
type memPictures map[string]map[string]string
func (p memPictures) Follows(state string) bool { _, ok := p[state]; return ok }
func (p memPictures) Keys(state string) ([]string, error) {
return memRead(p).Keys(state)
}
func (p memPictures) Get(state, key string) ([]byte, bool, error) { return memRead(p).Get(state, key) }
@@ -189,13 +189,13 @@ func (h *Holder) openEnrolments() (map[string]Row, map[string]Enrolment, error)
// placements reads every node's placement, assembled from its keys.
func (h *Holder) placements() (map[string]delivery.Placement, error) {
keys, err := h.Read.Keys("mesh-delivery-assigner.placement")
keys, err := h.Read.Keys(PlacementRead)
if err != nil {
return nil, fmt.Errorf("placement cannot be listed: %w", err)
}
held := map[string][]byte{}
for _, k := range keys {
if raw, found, err := h.Read.Get("mesh-delivery-assigner.placement", k); err != nil {
if raw, found, err := h.Read.Get(PlacementRead, k); err != nil {
return nil, fmt.Errorf("placement key %s cannot be read: %w", k, err)
} else if found {
held[k] = raw
@@ -5,6 +5,7 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -13,7 +14,9 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/views"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
@@ -84,14 +87,58 @@ func (runtimeReader) Get(state, key string) ([]byte, bool, error) {
return e.Value, true, nil
}
// pictures are the states the enrolment follows rather than reads (views.Reader): the assigner's placement (ADR 0615 §1).
type pictures interface {
Follows(state string) bool
Keys(state string) ([]string, error)
Get(state, key string) ([]byte, bool, error)
}
// followedReader reads a followed state from its picture and every other state through rest.
type followedReader struct {
pics pictures
rest Reader
}
func (r followedReader) Keys(state string) ([]string, error) {
if r.pics != nil && r.pics.Follows(state) {
return r.pics.Keys(state)
}
return r.rest.Keys(state)
}
func (r followedReader) Get(state, key string) ([]byte, bool, error) {
if r.pics != nil && r.pics.Follows(state) {
return r.pics.Get(state, key)
}
return r.rest.Get(state, key)
}
// PlacementRead is the assigner's placement as the enrolment reads it, and PlacementTool the assigner's read tool the
// manifest invokes for its first view (ADR 0615, DLV-M6).
const (
PlacementRead = delivery.AssignerModule + "." + delivery.PlacementState
PlacementTool = delivery.AssignerModule + ".assigner_read_placement"
)
// followed is the enrolment's pictures of the assigner's placement, started at once: its first view asked through
// PlacementTool, then the assigner's views family; past its bound the finding in the enrolment's own findings.
func followed() *views.Reader {
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { logf("["+Module+"] "+f, a...) }, Findings: views.OwnFindings{},
Acts: handacts.Recorder{Ask: stdio.Ask}}
v.Follow(delivery.AssignerModule, delivery.PlacementState, PlacementTool)
v.Start(context.Background())
return v
}
// SeatEventSubject is where the seat says an event: mesh.seat.<seat>.event.<name>.
func SeatEventSubject(event string) string { return "mesh.seat." + Seat + ".event." + event }
// newHolder is the holder over the runtime.
func newHolder() *Holder {
func newHolder(v *views.Reader) *Holder {
call := Call(stdio.Ask)
rec := handacts.Recorder{Ask: stdio.Ask}
rd := runtimeReader{}
rd := followedReader{pics: v, rest: runtimeReader{}}
client := asker.New("asked")
return &Holder{
Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call, read: rd}, Asker: client,
@@ -169,10 +216,11 @@ func loadedVia(rd Reader) func(string) (bool, error) {
}
func main() {
h := newHolder()
v := followed()
h := newHolder(v)
client := h.Asker.(*asker.Client)
h.Run(func() error { return client.Listen(h.Warranted, h.Said) })
if err := stdio.Serve("", Tools(h)); err != nil {
if err := stdio.Serve("", append(Tools(h), v.Tools("enrolment")...)); err != nil {
logf("%v", err)
os.Exit(1)
}
@@ -82,9 +82,11 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) {
Scope string `json:"scope"`
Serves []string `json:"serves"`
} `json:"claims"`
Emits []string `json:"emits"`
Reads []string `json:"reads"`
State []json.RawMessage `json:"state"`
Emits []string `json:"emits"`
Reads []string `json:"reads"`
Invokes []string `json:"invokes"`
Tools []string `json:"tools"`
State []json.RawMessage `json:"state"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
@@ -96,10 +98,20 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) {
if slices.Contains(m.Reads, "mesh-controller.lease") {
t.Error("the manifest reads the controller's lease, which the code no longer reads (issue 1126)")
}
// The assigner's placement is followed, never read (ADR 0615 §1, DLV-M6): its read tool invoked, its views family consumed.
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)
}
}
if !slices.Contains(m.Invokes, PlacementTool) || !slices.Equal(m.Tools, []string{"enrolment_first_views", "enrolment_first_view_again"}) {
t.Errorf("invokes %v, tools %v: want %s and the first-view tools", m.Invokes, m.Tools, PlacementTool)
}
var heard []string
for _, e := range EndEvents {
heard = append(heard, Seat+"."+e)
}
heard = append(heard, "mesh-delivery-assigner.views.placement")
if !slices.Equal(m.Consumes, heard) {
t.Errorf("consumes %v, the end events are %v", m.Consumes, heard)
}
@@ -112,7 +124,7 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) {
t.Errorf("the verb %s has no tool", v)
}
}
if len(m.State) != 2 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool {
if len(m.State) != 3 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool {
var d struct {
Name string `json:"name"`
TTL bool `json:"key-ttl"`
+10 -4
View File
@@ -20,7 +20,8 @@
"mesh-enrolment.node-enrolled",
"mesh-enrolment.enrolment-refused",
"mesh-enrolment.enrolment-revoked",
"mesh-enrolment.enrolment-expired"
"mesh-enrolment.enrolment-expired",
"mesh-delivery-assigner.views.placement"
],
"uses": [
"operator-channel"
@@ -31,10 +32,10 @@
"seat:mesh-placement.propose",
"seat:mesh-vault.enrolment-token",
"seat:mesh-vault.node-account",
"seat:mesh-vault.sign"
"seat:mesh-vault.sign",
"mesh-delivery-assigner.assigner_read_placement"
],
"reads": [
"mesh-delivery-assigner.placement",
"mesh-grants.user-list",
"mesh-vault.signers",
"node-engine.have"
@@ -47,7 +48,12 @@
{
"name": "asked",
"history": 5
}
},
"findings"
],
"tools": [
"enrolment_first_views",
"enrolment_first_view_again"
],
"own-secrets": {
"broker": "${dir:state}/broker"
+542
View File
@@ -0,0 +1,542 @@
// Package firstview is a process's pictures of the indexer's and the assigner's states it used to read (novox/hq ADR
// 0615 §1, design 25 § 12.2), for the mesh-delivery parts that run as processes (the builder, the checker, the teller,
// the view, the watchman): each state is asked once through the owner's ordinary read tool with `values` (the first
// view), then kept from the owner's views family, asked again only on a gap or by hand (mesh-sdk go/views Follower).
//
// A Picture reads as the key-value bucket the process used to bind (jetstream.KeyValue: Get, Keys, ListKeys, Watch,
// WatchAll), so a reader's code is unchanged; every write is refused, for the owner's state is the owner's alone. The
// picture is kept in memory: the owner's state is the truth and a restart asks again (ADR 0615 §4).
//
// It is the process side of mesh-sdk go/views Reader (the stdio side) and moves there whole when mesh-sdk takes it.
package firstview
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
)
// ErrReadOnly is every write to a picture: the state is its owner's.
var ErrReadOnly = errors.New("a first view is read-only: the state is its owner's")
// ReadTool is the owner's ordinary read tool of state, as a reader's `invokes` names it (design 25 § 12.2):
// `<owner>.<short>_read_<state>`, short the owner's name past its last dash-separated `mesh-delivery-` prefix
// (indexer, assigner) and the state's dashes as underscores.
func ReadTool(owner, state string) string {
short := strings.TrimPrefix(owner, "mesh-delivery-")
return owner + "." + short + "_read_" + strings.ReplaceAll(state, "-", "_")
}
// Reader is one process's pictures of its owners' states.
type Reader struct {
// Module is the reading module, named in the finding of a first view unanswered past its bound.
Module string
// Call asks a module's tool (process.Conn.Call): the read tools are asked with `values`.
Call func(key string, body any) (json.RawMessage, error)
// Findings is the reader's own state `findings`, where the condition keeper raises and clears the condition of a
// first view unanswered past its bound (ADR 0615 §6); nil says it in the log only.
Findings func() jetstream.KeyValue
Logf func(string, ...any)
// Now, Bound and Wait are for tests: zero are the clock, views.Bound and five seconds.
Now func() time.Time
Bound time.Duration
Wait time.Duration
mu sync.Mutex
pics map[string]*Picture // by bucket name <owner>_<state>
order []string
}
func (r *Reader) logf(format string, args ...any) {
if r.Logf != nil {
r.Logf(format, args...)
}
}
// Follow adds the owner's state to follow through its read tool (ReadTool). Call it before Start.
func (r *Reader) Follow(owner, state string) *Picture {
r.mu.Lock()
defer r.mu.Unlock()
if r.pics == nil {
r.pics = map[string]*Picture{}
}
bucket := owner + "_" + state
if p := r.pics[bucket]; p != nil {
return p
}
tool := ReadTool(owner, state)
p := &Picture{bucket: bucket, ctx: context.Background()}
call := r.Call
ask := views.AskVia(func(key string, body any) (json.RawMessage, error) {
m, _ := body.(map[string]any)
if m == nil {
m = map[string]any{}
}
m["values"] = true
if call == nil {
return nil, errors.New("this reader asks no tool")
}
return call(key, m)
}, tool)
p.f = &views.Follower{Owner: owner, Tool: tool, State: state, Ask: ask, Logf: r.Logf, Now: r.Now, Bound: r.Bound, Wait: r.Wait,
Raise: func(s views.Status) { r.finding(s, true) }, Clear: func(s views.Status) { r.finding(s, false) },
Changed: p.changed}
r.pics[bucket] = p
r.order = append(r.order, bucket)
return p
}
// Picture is the picture of the bucket the process used to bind, by its bus name (`<owner>_<state>`); nil when not
// followed.
func (r *Reader) Picture(bucket string) *Picture {
r.mu.Lock()
defer r.mu.Unlock()
return r.pics[bucket]
}
// KeyValue opens a followed state's picture by its bus name, where a process opened the bucket (process.BucketOpener,
// as buildrun.FollowPlacement takes it); any other bucket is refused, for the reader reads its owners' states by their
// first views alone.
func (r *Reader) KeyValue(_ context.Context, bucket string) (jetstream.KeyValue, error) {
if p := r.Picture(bucket); p != nil {
return p, nil
}
return nil, fmt.Errorf("%s reads %s by its owner's first view alone, and does not follow it", r.Module, bucket)
}
// Followers are the followers, in the order they were added.
func (r *Reader) Followers() []*views.Follower {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*views.Follower, 0, len(r.order))
for _, b := range r.order {
out = append(out, r.pics[b].f)
}
return out
}
func (r *Reader) finding(s views.Status, raised bool) {
if raised {
r.logf("the first view %s of %s's %s is unanswered past its bound: %s", s.Tool, s.Owner, s.State, s.Error)
}
if r.Findings == nil {
return
}
kv := r.Findings()
if kv == nil {
return
}
at := time.Now()
if r.Now != nil {
at = r.Now()
}
key, value, err := views.UnansweredFinding(r.Module, s, at)
if err == nil && raised {
_, err = kv.Put(context.Background(), key, value)
} else if err == nil {
err = kv.Delete(context.Background(), key)
}
if err != nil {
r.logf("views: the finding of the first view %s is not written: %v", s.Tool, err)
}
}
// Hear is the handler of the owner's views family on the reader's durable consumer (cred.EventConsumer on the owner's
// views stream): each event to the follower of its state.
func (r *Reader) Hear(ctx context.Context, owner string) func(jetstream.Msg) {
fs := map[string]*views.Follower{}
for _, f := range r.Followers() {
if f.Owner == owner {
fs[f.State] = f
}
}
return views.Hear(ctx, fs, r.logf)
}
// Owners are the owners followed, sorted.
func (r *Reader) Owners() []string {
var out []string
for _, f := range r.Followers() {
if !slices.Contains(out, f.Owner) {
out = append(out, f.Owner)
}
}
slices.Sort(out)
return out
}
// Ask asks every first view until answered, each on a goroutine of its own, so the process starts at once and waits for
// an owner that is down, saying so (ADR 0615 §6). Said, when set, keeps each status (views.KeepStatus).
func (r *Reader) Ask(ctx context.Context, said func(views.Status)) {
for _, f := range r.Followers() {
if said != nil {
f.Said = said
}
go func() {
if err := f.Start(ctx); err != nil {
r.logf("the first view %s was not answered: %v", f.Tool, err)
}
}()
}
}
// Ready is closed once every followed state had its first view.
func (r *Reader) Ready() <-chan struct{} {
fs := r.Followers()
done := make(chan struct{})
go func() {
for _, f := range fs {
<-f.Ready()
}
close(done)
}()
return done
}
// Picture is one owner's state as the process reads it: a read-only jetstream.KeyValue over a views.Follower. Its
// revisions are the picture's own, one per change it took, so a watcher sees them rise; they are not the owner's.
type Picture struct {
jetstream.KeyValue // never set: every method a reader may call is the picture's own
bucket string
ctx context.Context
f *views.Follower
mu sync.Mutex
seq uint64
revs map[string]uint64
watchers []*watcher
}
// Follower is the picture's follower (its status, a gap or a hand ask).
func (p *Picture) Follower() *views.Follower { return p.f }
func (p *Picture) changed(key string, value json.RawMessage, deleted bool, at time.Time) {
p.mu.Lock()
p.seq++
if p.revs == nil {
p.revs = map[string]uint64{}
}
p.revs[key] = p.seq
op := jetstream.KeyValuePut
if deleted {
op, value = jetstream.KeyValueDelete, nil
}
e := entry{bucket: p.bucket, key: key, value: value, rev: p.seq, at: at, op: op}
ws := slices.Clone(p.watchers)
p.mu.Unlock()
for _, w := range ws {
w.send(e)
}
}
// answered is nil once the first view is answered, else the error a read before it gets (never a wait).
func (p *Picture) answered() error {
select {
case <-p.f.Ready():
return nil
default:
return fmt.Errorf("no first view of %s yet: %s has not answered", p.bucket, p.f.Tool)
}
}
func (p *Picture) ready(ctx context.Context) error {
select {
case <-p.f.Ready():
return nil
case <-ctx.Done():
return fmt.Errorf("no first view of %s yet (%s has not answered): %w", p.bucket, p.f.Tool, ctx.Err())
}
}
// Bucket is the bus name of the owner's state the picture stands for.
func (p *Picture) Bucket() string { return p.bucket }
// Get is one key; before the first view is answered it is an error, never a wait.
func (p *Picture) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) {
if err := p.answered(); err != nil {
return nil, err
}
v, ok := p.f.Get(key)
if !ok {
return nil, jetstream.ErrKeyNotFound
}
p.mu.Lock()
rev := p.revs[key]
p.mu.Unlock()
return entry{bucket: p.bucket, key: key, value: v, rev: rev, op: jetstream.KeyValuePut}, nil
}
// Keys is every key; none is jetstream.ErrNoKeysFound, as the bus says it; before the first view is answered it is an
// error, never a wait.
func (p *Picture) Keys(_ context.Context, _ ...jetstream.WatchOpt) ([]string, error) {
if err := p.answered(); err != nil {
return nil, err
}
var keys []string
for k := range p.f.Values() {
keys = append(keys, k)
}
if len(keys) == 0 {
return nil, jetstream.ErrNoKeysFound
}
slices.Sort(keys)
return keys, nil
}
// ListKeys is Keys on a channel.
func (p *Picture) ListKeys(ctx context.Context, opts ...jetstream.WatchOpt) (jetstream.KeyLister, error) {
keys, err := p.Keys(ctx, opts...)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, err
}
ch := make(chan string, len(keys))
for _, k := range keys {
ch <- k
}
close(ch)
return lister{ch}, nil
}
// WatchAll is Watch of every key.
func (p *Picture) WatchAll(ctx context.Context, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
return p.Watch(ctx, ">", opts...)
}
// Watch hands, once the first view is answered, every current value of the keys matching (a subject pattern: `*` one
// token, `>` the rest), then nil, then each change, as the bus's watch does. Options are not read: a picture holds no
// history, and deletes are always handed.
func (p *Picture) Watch(ctx context.Context, keys string, _ ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
w := &watcher{ch: make(chan jetstream.KeyValueEntry, 64), match: keys, wake: make(chan struct{}, 1), stop: make(chan struct{})}
go func() {
defer close(w.ch)
if p.ready(ctx) != nil {
return
}
p.mu.Lock()
p.watchers = append(p.watchers, w)
p.mu.Unlock()
defer p.unwatch(w)
vals := p.f.Values()
names := make([]string, 0, len(vals))
for k := range vals {
names = append(names, k)
}
slices.Sort(names)
p.mu.Lock()
first := make([]jetstream.KeyValueEntry, 0, len(names)+1)
for _, k := range names {
if matches(keys, k) {
first = append(first, entry{bucket: p.bucket, key: k, value: vals[k], rev: p.revs[k], op: jetstream.KeyValuePut})
}
}
p.mu.Unlock()
first = append(first, nil)
w.run(ctx, first)
}()
return w, nil
}
func (p *Picture) unwatch(w *watcher) {
p.mu.Lock()
defer p.mu.Unlock()
p.watchers = slices.DeleteFunc(p.watchers, func(x *watcher) bool { return x == w })
}
// The writes and the reads a picture cannot answer are refused, never handed to a bucket.
func (p *Picture) Put(context.Context, string, []byte) (uint64, error) { return 0, ErrReadOnly }
func (p *Picture) PutString(context.Context, string, string) (uint64, error) { return 0, ErrReadOnly }
func (p *Picture) Create(context.Context, string, []byte, ...jetstream.KVCreateOpt) (uint64, error) {
return 0, ErrReadOnly
}
func (p *Picture) Update(context.Context, string, []byte, uint64) (uint64, error) {
return 0, ErrReadOnly
}
func (p *Picture) Delete(context.Context, string, ...jetstream.KVDeleteOpt) error { return ErrReadOnly }
func (p *Picture) Purge(context.Context, string, ...jetstream.KVDeleteOpt) error { return ErrReadOnly }
func (p *Picture) PurgeDeletes(context.Context, ...jetstream.KVPurgeOpt) error { return ErrReadOnly }
func (p *Picture) GetRevision(context.Context, string, uint64) (jetstream.KeyValueEntry, error) {
return nil, errors.New("a first view holds no history")
}
func (p *Picture) History(context.Context, string, ...jetstream.WatchOpt) ([]jetstream.KeyValueEntry, error) {
return nil, errors.New("a first view holds no history")
}
func (p *Picture) WatchFiltered(ctx context.Context, keys []string, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
if len(keys) != 1 {
return nil, errors.New("a first view watches one pattern")
}
return p.Watch(ctx, keys[0], opts...)
}
func (p *Picture) ListKeysFiltered(ctx context.Context, filters ...string) (jetstream.KeyLister, error) {
keys, err := p.Keys(ctx)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, err
}
ch := make(chan string, len(keys))
for _, k := range keys {
if len(filters) == 0 || slices.ContainsFunc(filters, func(f string) bool { return matches(f, k) }) {
ch <- k
}
}
close(ch)
return lister{ch}, nil
}
func (p *Picture) Status(context.Context) (jetstream.KeyValueStatus, error) {
return nil, errors.New("a first view has no bucket status: the owner's read tool says its fill")
}
// matches says whether key matches a subject pattern of the bus's watch.
func matches(pattern, key string) bool {
if pattern == "" || pattern == ">" {
return true
}
pt, kt := strings.Split(pattern, "."), strings.Split(key, ".")
for i, t := range pt {
if t == ">" {
return len(kt) > i
}
if i >= len(kt) || (t != "*" && t != kt[i]) {
return false
}
}
return len(pt) == len(kt)
}
// watcher hands a watch's entries in order: the current values first, then each change heard, never blocking the
// follower (the changes wait in a queue of its own).
type watcher struct {
ch chan jetstream.KeyValueEntry
match string
wake chan struct{}
stop chan struct{}
once sync.Once
mu sync.Mutex
queue []jetstream.KeyValueEntry
}
func (w *watcher) send(e entry) {
if !matches(w.match, e.key) {
return
}
w.mu.Lock()
w.queue = append(w.queue, e)
w.mu.Unlock()
select {
case w.wake <- struct{}{}:
default:
}
}
func (w *watcher) run(ctx context.Context, first []jetstream.KeyValueEntry) {
out := func(e jetstream.KeyValueEntry) bool {
select {
case w.ch <- e:
return true
case <-ctx.Done():
return false
case <-w.stop:
return false
}
}
for _, e := range first {
if !out(e) {
return
}
}
for {
w.mu.Lock()
q := w.queue
w.queue = nil
w.mu.Unlock()
for _, e := range q {
if !out(e) {
return
}
}
select {
case <-w.wake:
case <-ctx.Done():
return
case <-w.stop:
return
}
}
}
func (w *watcher) Updates() <-chan jetstream.KeyValueEntry { return w.ch }
func (w *watcher) Stop() error {
w.once.Do(func() { close(w.stop) })
return nil
}
type lister struct{ ch chan string }
func (l lister) Keys() <-chan string { return l.ch }
func (l lister) Stop() error { return nil }
type entry struct {
bucket, key string
value []byte
rev uint64
at time.Time
op jetstream.KeyValueOp
}
func (e entry) Bucket() string { return e.bucket }
func (e entry) Key() string { return e.key }
func (e entry) Value() []byte { return e.value }
func (e entry) Revision() uint64 { return e.rev }
func (e entry) Created() time.Time { return e.at }
func (e entry) Delta() uint64 { return 0 }
func (e entry) Operation() jetstream.KeyValueOp { return e.op }
// ViewsStream is the bus name of owner's views family (views.IndexerStream, views.AssignerStream): `<short>-views`
// as the owner's old `streams` section names it.
func ViewsStream(owner string) string {
return catalogue.StreamBusName(strings.TrimPrefix(owner, "mesh-delivery-") + "-views")
}
// Start hears each owner's views family on the process's durable consumer of it (its manifest's `consumes`
// `<owner>.views.<state>`), keeps each status in the process's state `first-views` and hears the hand asks the tools
// bundle's `<reader>_first_view_again` writes there (views.StatusTools), then asks every first view (Ask). A consumer
// the bus does not have yet is said: the picture is then the first view's alone, asked again on a gap or by hand.
func (r *Reader) Start(ctx context.Context, js jetstream.JetStream, cred process.Account, status *process.AwaitedBucket) {
for _, owner := range r.Owners() {
stream := ViewsStream(owner)
cons, err := cred.EventConsumer(ctx, js, stream)
if err == nil {
_, err = process.HearEvents(cons, r.Hear(ctx, owner), r.logf)
}
if err != nil {
r.logf("the consumer %s on %s is not heard (%v): %s's states are the first view's alone, asked again by hand, until the %s starts again",
cred.EventConsumerName(), stream, err, owner, r.Module)
}
}
var said func(views.Status)
if status != nil {
status.Ready(func(kv jetstream.KeyValue) {
if err := views.HearAgain(ctx, kv, r.Followers(), r.logf); err != nil {
r.logf("views: the hand asks of %s's first views are not heard (%v) until it starts again", r.Module, err)
}
})
said = func(s views.Status) {
if kv := status.KV(); kv != nil {
views.KeepStatus(ctx, kv, r.logf)(s)
}
}
}
r.Ask(ctx, said)
}
+109
View File
@@ -0,0 +1,109 @@
// Package firstviewtest is an owner of a state as a reader's whole-module test needs it (novox/hq ADR 0615): the
// owner's read tool answering the first view from its bucket at its mark, and its views family said by the real
// views emitter onto its views stream, which the reader's durable consumer reads.
package firstviewtest
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/firstview"
)
// Serve is owner's state on the bus with the owner's side of it: the bucket `<owner>_<state>` (made when absent, history
// 1), the owner's marks, its views stream with a durable consumer per reader (`<node>_<module>`), the emitter saying
// each change, and the read tool (firstview.ReadTool) answering on nc. It returns the bucket the test writes.
func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn, owner, state string, readers ...string) jetstream.KeyValue {
t.Helper()
bucket := owner + "_" + state
kv, err := js.KeyValue(ctx, bucket)
if err != nil {
if kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 1}); err != nil {
t.Fatal(err)
}
}
marks, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: owner + "_" + views.MarksState, History: 1})
if err != nil {
t.Fatal(err)
}
stream := firstview.ViewsStream(owner)
if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{process.ModuleStreamSubject(owner, views.EventPrefix+">")}}); err != nil {
t.Fatal(err)
}
for _, r := range readers {
if _, err := js.CreateOrUpdateConsumer(ctx, stream, jetstream.ConsumerConfig{Durable: r, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
}
em := views.Emitter{Owner: owner, Subject: func(e string) string { return process.ModuleStreamSubject(owner, e) },
Publish: views.JetStreamPublish(js), Marks: marks}
if err := em.Start(ctx, state, kv, nil); err != nil {
t.Fatal(err)
}
tool := strings.TrimPrefix(firstview.ReadTool(owner, state), owner+".")
sub, err := nc.Subscribe("mesh.mod."+owner+".call."+tool+".>", func(m *nats.Msg) {
var in struct {
After string `json:"after"`
}
_ = json.Unmarshal(m.Data, &in)
a, err := views.FirstView(owner, state, Source{ctx, kv}, Source{ctx, marks}, in.After, time.Now())
out := map[string]any{"result": a, "node": "n0"}
if err != nil {
out = map[string]any{"error": err.Error()}
}
raw, _ := json.Marshal(out)
_ = m.Respond(raw)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
return kv
}
// Grants are the subjects a reader's account publishes to follow owner's state (its `invokes` and `consumes`): the
// read tool's call subjects, its durable consumer's info, next and acknowledgements on the owner's views stream.
func Grants(owner, state, durable string) []string {
tool := strings.TrimPrefix(firstview.ReadTool(owner, state), owner+".")
stream := firstview.ViewsStream(owner)
return []string{"mesh.mod." + owner + ".call." + tool + ".>", "mesh.mod." + owner + ".call." + tool,
"$JS.API.CONSUMER.INFO." + stream + "." + durable, "$JS.API.CONSUMER.MSG.NEXT." + stream + "." + durable,
"$JS.ACK." + stream + "." + durable + ".>"}
}
// Source is a bucket as a views.Source.
type Source struct {
Ctx context.Context
KV jetstream.KeyValue
}
// Keys is every key; none is no error.
func (s Source) Keys() ([]string, error) {
keys, err := s.KV.Keys(s.Ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}
// Get is one key's value and revision.
func (s Source) Get(key string) (*views.Entry, bool, error) {
e, err := s.KV.Get(s.Ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return &views.Entry{Value: e.Value(), Revision: e.Revision()}, true, nil
}
+4 -1
View File
@@ -2,4 +2,7 @@ module git.novox.be/novox/mesh-delivery/sdk
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats.go v1.54.0
)