Files
mesh-host/internal/selfcompose/data_test.go
T
jschoubben 122f4dd727 Read a done build once, and work out the data items in the scope the pass composed in (ADR 0577)
The data items after a pass re-read each placed module's build over the bus (about 55 per pass on
ace, g14 and shanks); a done build never changes, so the composer keeps it. The items are now
worked out with the pass's own kept builds and modules run inside the node's own, as composed.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 00:18:30 +01:00

174 lines
7.6 KiB
Go

package selfcompose
import (
"context"
"errors"
"slices"
"strings"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// fakeData is the engine's state data as a map, counting the writes and deletes made.
type fakeData struct {
mu sync.Mutex
kv map[string][]byte
puts []string
deletes []string
fail error
}
func (f *fakeData) Get(_ context.Context, key string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.kv[key], nil
}
func (f *fakeData) Put(_ context.Context, key string, value []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return f.fail
}
f.kv[key] = value
f.puts = append(f.puts, key)
return nil
}
func (f *fakeData) Delete(_ context.Context, key string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.kv, key)
f.deletes = append(f.deletes, key)
return nil
}
func (f *fakeData) Keys(_ context.Context, pattern string) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for k := range f.kv {
if matches(pattern, k) {
out = append(out, k)
}
}
return out, nil
}
// ADR 0577 clause 1 (and its check in clause 5): after a pass that composed something new, the engine keeps each placed
// module's data items in its state data, key `<node>.<module>`, with its composition's digest: written when the module's
// items change, not written when they do not, and deleted when the module leaves the node; a key of another node is never
// touched, a pass that handed nothing new reads nothing, and a write that failed is tried again at the next pass.
func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
item := func(module, id, path string) catalogue.PlacedDataItem {
return catalogue.PlacedDataItem{Module: module, ID: id, Class: "valuable", Path: path, Measure: catalogue.MeasureWalk}
}
store := &fakeData{kv: map[string][]byte{
// Left by a module that went while the engine was down, and another node's key.
"n1.gone": []byte(`{"v":1}`), "n2.web": []byte(`{"v":1}`)}}
var items map[string][]catalogue.PlacedDataItem
var asked int
setFor(t, &dataItemsOf, func(context.Context, frombus.Reader, frombus.Mesh, frombus.Scope) (map[string][]catalogue.PlacedDataItem, []string, error) {
asked++
return items, nil, nil
})
// The engine's grant lets it read its own node's keys, as the listing at a run's first pass does, and no other's.
if !grantAllows(t, "n1", delivery.EngineDataBucket, "n1.*") || grantAllows(t, "n1", delivery.EngineDataBucket, "n2.*") {
t.Error("the node's grant reads other keys of the engine's state data than its own")
}
c := New(Env{Node: "n1", Data: store})
webKey, _ := delivery.ComposedKey("n1", "web", delivery.SourcePrefix2+strings.Repeat("a", 64))
nodeKey, _ := delivery.ComposedNodeKey("n1")
for _, step := range []struct {
name string
due bool
items map[string][]catalogue.PlacedDataItem
digests map[string]string // composed key to digest handed
fail error
puts []string
deletes []string
asked int
// want is each key's items and digest after the step.
want map[string]delivery.EngineData
}{
{name: "the run's first pass writes each module's items and deletes what left", due: true,
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}, "oldie": {item("oldie", "x", "/srv/oldie")}, "bare": nil},
digests: map[string]string{webKey: "sha256:w1", nodeKey: "sha256:n1"},
puts: []string{"n1.oldie", "n1.web"}, deletes: []string{"n1.gone"}, asked: 1,
want: map[string]delivery.EngineData{
"n1.web": {Module: "web", Digest: "sha256:w1", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")}},
"n1.oldie": {Module: "oldie", Digest: "sha256:n1", Items: []catalogue.PlacedDataItem{item("oldie", "x", "/srv/oldie")}}}},
{name: "a pass that handed nothing new reads nothing", due: false, asked: 1},
{name: "a pass that changes no module's items writes nothing", due: true,
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}, "oldie": {item("oldie", "x", "/srv/oldie")}},
digests: map[string]string{webKey: "sha256:w1", nodeKey: "sha256:n1"}, asked: 2},
{name: "a write that fails is tried again at the next pass", due: true, fail: errors.New("the bus said no"),
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db2")}, "oldie": {item("oldie", "x", "/srv/oldie")}},
digests: map[string]string{webKey: "sha256:w2", nodeKey: "sha256:n1"}, asked: 3},
{name: "a module whose composition changed its items is written again", due: false,
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db2")}, "oldie": {item("oldie", "x", "/srv/oldie")}},
digests: map[string]string{webKey: "sha256:w2", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 4,
want: map[string]delivery.EngineData{
"n1.web": {Module: "web", Digest: "sha256:w2", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db2")}}}},
{name: "a module that left the node is deleted", due: true,
items: map[string][]catalogue.PlacedDataItem{"oldie": {item("oldie", "x", "/srv/oldie")}},
digests: map[string]string{nodeKey: "sha256:n1"}, deletes: []string{"n1.web"}, asked: 5},
} {
t.Run(step.name, func(t *testing.T) {
store.mu.Lock()
store.puts, store.deletes, store.fail = nil, nil, step.fail
store.mu.Unlock()
items = step.items
c.mu.Lock()
c.handed = step.digests
c.dataDue = c.dataDue || step.due
c.mu.Unlock()
c.publishData(context.Background())
slices.Sort(store.puts)
if !slices.Equal(store.puts, step.puts) || !slices.Equal(store.deletes, step.deletes) || asked != step.asked {
t.Fatalf("wrote %v and deleted %v, items asked %d times; want %v, %v, %d", store.puts, store.deletes, asked,
step.puts, step.deletes, step.asked)
}
for key, want := range step.want {
got, err := delivery.DecodeEngineData(store.kv[key])
if err != nil || got.Node != "n1" || got.Module != want.Module || got.Digest != want.Digest ||
!slices.Equal(got.Items, want.Items) {
t.Errorf("%s holds %+v (%v), want %+v", key, got, err, want)
}
}
if store.kv["n2.web"] == nil {
t.Error("another node's key was deleted")
}
})
}
// The items are worked out from what the pass read: a done build is read over the bus once, not again for the data
// items after the pass (a module in a running build is read again, since it may land).
b := newFakeBus()
done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest([]byte(lemurs))})
running, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: "building"})
fp := delivery.SourcePrefix2 + strings.Repeat("b", 64)
doneKey, _ := delivery.BuilderBuildKey("web", fp)
runningKey, _ := delivery.BuilderBuildKey("pg", fp)
b.kv[delivery.BuilderBuildsBucket+"/"+doneKey], b.kv[delivery.BuilderBuildsBucket+"/"+runningKey] = done, running
r := reader{c: New(b.env())}
for range 3 {
if got, err := r.Build(context.Background(), "web", fp); err != nil || got.State != delivery.BuildDone {
t.Fatalf("the done build: %+v, %v", got, err)
}
if got, err := r.Build(context.Background(), "pg", fp); err != nil || got.State != "building" {
t.Fatalf("the running build: %+v, %v", got, err)
}
}
reads := map[string]int{}
for _, read := range b.reads {
reads[read[1]]++
}
if reads[doneKey] != 1 || reads[runningKey] != 3 {
t.Errorf("the builds were read %v times over the bus; want the done one once, the running one each time", reads)
}
}