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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)), ", ")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user