Add the chain package and its buckets: builds, rollouts and assignments with history 64 (issue 1089)

internal/chain holds typed Build, Rollout and Assignment and a Backend with no delete or purge, in memory and on the bus. The builds and rollouts buckets are asserted with history 64, no age and values up to just under a mebibyte; the assignments bucket gets the same, keeping its marker age because a stream cannot be made to disallow message ages. Migration 0104 makes the one-row chain_moved switch. The controller keeps no consolidated schema, so 0104 is the whole schema change. Nothing reads the buckets yet.
This commit is contained in:
2026-10-18 02:39:11 +02:00
parent 54070f5bc3
commit fc55785956
15 changed files with 1175 additions and 9 deletions
+312
View File
@@ -0,0 +1,312 @@
package chain
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/broker"
)
func at(h int) time.Time { return time.Date(2026, 10, 18, h, 0, 0, 0, time.UTC) }
// **A build is kept whole and read back with its reuse key and what it published** (ADR 0408 §1): its module,
// repository, commit, reuse key, and an artifact by digest or a package release by name and version.
func TestABuildIsKeptWholeAndReadBackWithItsReuseKeyAndWhatItPublished(t *testing.T) {
ctx := context.Background()
c := InMemory(nil)
artifact := Build{ID: "b1", Repository: "mesh-catalog", Module: "notes", Commit: "c1", ReuseKey: "rk-1",
Published: &Published{Artifact: "sha256:aa"}, Asked: at(1), At: at(2),
Made: []Artifact{{Name: "notes", Kind: "image", Reference: "sha256:aa"}}}
pkg := Build{ID: "b2", Repository: "mesh-sdk", Module: "sdk", Commit: "c2", ReuseKey: "rk-2",
Published: &Published{Package: &Package{Name: "mesh-sdk", Version: "1.4.0"}}}
for _, b := range []Build{artifact, pkg} {
if _, err := c.Builds.Create(ctx, b.ID, b); err != nil {
t.Fatal(err)
}
}
got, ok, err := c.Builds.Get(ctx, "b1")
if err != nil || !ok {
t.Fatalf("read b1: %v %v", ok, err)
}
if got.Value.Module != "notes" || got.Value.Repository != "mesh-catalog" || got.Value.Commit != "c1" ||
got.Value.ReuseKey != "rk-1" || got.Value.Published.Artifact != "sha256:aa" || len(got.Value.Made) != 1 {
t.Fatalf("the build came back as %+v", got.Value)
}
got, _, _ = c.Builds.Get(ctx, "b2")
if p := got.Value.Published; p == nil || p.Package == nil || p.Package.Name != "mesh-sdk" || p.Package.Version != "1.4.0" {
t.Fatalf("the package came back as %+v", p)
}
if _, ok, _ := c.Builds.Get(ctx, "nope"); ok {
t.Fatal("a key nobody wrote holds a build")
}
}
// **A write against a revision that moved is refused and nothing is laid over it** (ADR 0408 §2).
func TestAWriteAgainstAMovedRevisionIsRefusedAndNothingIsLaidOver(t *testing.T) {
ctx := context.Background()
c := InMemory(nil)
first, err := c.Assignments.Create(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "c1"})
if err != nil {
t.Fatal(err)
}
if _, err := c.Assignments.Create(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes"}); !errors.Is(err, ErrExists) {
t.Fatalf("a create of a key that holds a value answered %v", err)
}
second, err := c.Assignments.Update(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "c2"}, first)
if err != nil {
t.Fatal(err)
}
// A writer that read the first revision writes now.
if _, err := c.Assignments.Update(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "stale"}, first); !errors.Is(err, ErrMoved) {
t.Fatalf("a write at a moved revision answered %v", err)
}
got, _, _ := c.Assignments.Get(ctx, "anchor.notes")
if got.Value.Commit != "c2" || got.Revision != second {
t.Fatalf("the key that moved was laid over: %+v at %d", got.Value, got.Revision)
}
}
// **No key of the chain is ever removed** (ADR 0408 §4): the Backend has no delete and no purge, so nothing that holds
// one can remove a key; and an assignment of a module taken off its node is kept, with its earlier values.
func TestNoKeyOfTheChainIsEverRemoved(t *testing.T) {
var backend Backend = NewMemory()
if _, ok := backend.(interface {
Delete(context.Context, string, uint64) error
}); ok {
t.Fatal("the backend can delete")
}
if _, ok := backend.(interface {
Purge(context.Context, string) error
}); ok {
t.Fatal("the backend can purge")
}
if _, ok := any(InMemory(nil).Builds).(interface {
Delete(context.Context, string) error
}); ok {
t.Fatal("a bucket of the chain can delete")
}
ctx := context.Background()
c := InMemory(nil)
rev, _ := c.Assignments.Create(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "c1"})
if _, err := c.Assignments.Update(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "c1"}, rev); err != nil {
t.Fatal(err)
}
keys, err := c.Assignments.Keys(ctx)
if err != nil || len(keys) != 1 || keys[0] != "anchor.notes" {
t.Fatalf("keys %v %v", keys, err)
}
history, err := c.Assignments.History(ctx, "anchor.notes")
if err != nil || len(history) != 2 {
t.Fatalf("the key keeps %d values: %v", len(history), err)
}
}
// **Each key keeps up to sixty-four earlier values** (ADR 0408 §5), oldest first.
func TestAKeyKeepsItsEarlierValuesUpToSixtyFour(t *testing.T) {
ctx := context.Background()
c := InMemory(nil)
rev, _ := c.Rollouts.Create(ctx, "r1", Rollout{ID: "r1", State: "n0"})
for i := 1; i <= 70; i++ {
var err error
if rev, err = c.Rollouts.Update(ctx, "r1", Rollout{ID: "r1", State: "n" + string(rune('A'+i%26))}, rev); err != nil {
t.Fatal(err)
}
}
history, err := c.Rollouts.History(ctx, "r1")
if err != nil {
t.Fatal(err)
}
if len(history) != broker.ChainHistory {
t.Fatalf("the key keeps %d values and the bus keeps %d", len(history), broker.ChainHistory)
}
for i := 1; i < len(history); i++ {
if history[i].Revision <= history[i-1].Revision {
t.Fatal("the history is not oldest first")
}
}
}
// **A value over the largest message is refused, not cut, and the key is unchanged** (ADR 0408 §6).
func TestAValueOverTheLargestMessageIsRefusedNotCut(t *testing.T) {
ctx := context.Background()
c := InMemory(nil)
small := Build{ID: "b1", Repository: "r", Commit: "c"}
rev, err := c.Builds.Create(ctx, "b1", small)
if err != nil {
t.Fatal(err)
}
huge := Build{ID: "b1", Repository: "r", Commit: "c", Manifest: json.RawMessage(`"` + strings.Repeat("x", 1<<20) + `"`)}
if _, err := c.Builds.Update(ctx, "b1", huge, rev); !errors.Is(err, ErrTooLarge) {
t.Fatalf("a value of a mebibyte answered %v", err)
}
if _, err := c.Builds.Create(ctx, "b2", huge); !errors.Is(err, ErrTooLarge) {
t.Fatalf("a created value of a mebibyte answered %v", err)
}
got, _, _ := c.Builds.Get(ctx, "b1")
if got.Revision != rev || len(got.Value.Manifest) != 0 {
t.Fatalf("the key was changed: %+v", got)
}
if _, ok, _ := c.Builds.Get(ctx, "b2"); ok {
t.Fatal("a refused create left a key")
}
// The largest that fits is written whole.
fits := Build{ID: "b3", Manifest: json.RawMessage(`"` + strings.Repeat("x", broker.ChainValueBytes-1024) + `"`)}
if _, err := c.Builds.Create(ctx, "b3", fits); err != nil {
t.Fatalf("a value under the largest was refused: %v", err)
}
got, _, _ = c.Builds.Get(ctx, "b3")
if len(got.Value.Manifest) != len(fits.Manifest) {
t.Fatal("a value under the largest was cut")
}
}
// **A rollout names every assignment it changed, with the build before and after** (ADR 0408 §1).
func TestARolloutNamesEveryAssignmentItChangedWithBuildBeforeAndAfter(t *testing.T) {
ctx := context.Background()
c := InMemory(nil)
r := Rollout{ID: "r1", State: "rolling", Commits: []Commit{{Repository: "mesh-catalog", Commit: "c2"}},
Tiers: [][]string{{"notes"}}, Links: Links{Builds: []string{"b2"}, Changed: []Change{
{Node: "anchor", Module: "notes", Before: Ref{Build: "b1", Commit: "c1"}, After: Ref{Build: "b2", Commit: "c2"}},
{Node: "g14", Module: "notes", After: Ref{Build: "b2", Commit: "c2"}}}}}
if _, err := c.Rollouts.Create(ctx, "r1", r); err != nil {
t.Fatal(err)
}
got, _, _ := c.Rollouts.Get(ctx, "r1")
if len(got.Value.Changed) != 2 || got.Value.Changed[0].Before.Build != "b1" || got.Value.Changed[0].After.Build != "b2" ||
got.Value.Changed[1].Before.Build != "" || got.Value.Changed[1].After.Commit != "c2" {
t.Fatalf("the rollout came back as %+v", got.Value)
}
// And in the contract's words: `changed`, with `before` and `after` naming `build` and `commit`.
raw, _ := json.Marshal(got.Value)
for _, want := range []string{`"changed":[`, `"before":{"build":"b1","commit":"c1"}`, `"after":{"build":"b2","commit":"c2"}`,
`"builds":["b2"]`, `"commits":[`, `"tiers":[["notes"]]`} {
if !strings.Contains(string(raw), want) {
t.Errorf("the rollout's value lacks %s: %s", want, raw)
}
}
}
// **An assignment names its build, artifact or package, commit and rollout**, and keeps the fields its readers read
// under the names they read them by (ADR 0408 §3).
func TestAnAssignmentNamesBuildArtifactCommitAndRollout(t *testing.T) {
a := Assignment{Node: "anchor", Module: "notes", Assigned: at(3), RequiredBy: "wiki",
Provisioning: json.RawMessage(`{"plan":1}`), Build: "b2", Artifact: "sha256:bb", Commit: "c2", Rollout: "r1", Since: at(4)}
raw, _ := json.Marshal(a)
for _, want := range []string{`"node":"anchor"`, `"module":"notes"`, `"assigned":"2026-10-18T03:00:00Z"`,
`"required-by":"wiki"`, `"provisioning":{"plan":1}`, `"build":"b2"`, `"artifact":"sha256:bb"`, `"commit":"c2"`,
`"rollout":"r1"`, `"since":"2026-10-18T04:00:00Z"`} {
if !strings.Contains(string(raw), want) {
t.Errorf("the assignment's value lacks %s: %s", want, raw)
}
}
if a.Key() != "anchor.notes" {
t.Errorf("key %s", a.Key())
}
// An assignment of today carries none of the new fields, and says none: a reader of today reads the bytes it did.
raw, _ = json.Marshal(Assignment{Node: "anchor", Module: "notes", Assigned: at(3)})
if string(raw) != `{"node":"anchor","module":"notes","assigned":"2026-10-18T03:00:00Z"}` {
t.Errorf("an assignment without a chain is %s", raw)
}
// A package in place of an artifact.
raw, _ = json.Marshal(Assignment{Node: "a", Module: "m", Package: &Package{Name: "n", Version: "1"}})
if !strings.Contains(string(raw), `"package":{"name":"n","version":"1"}`) {
t.Errorf("the package is %s", raw)
}
}
// **The writer without the lease writes nothing** (ADR 0408 §2): it reads, and every create and update is refused with
// the bucket untouched.
func TestTheWriterWithoutTheLeaseWritesNothing(t *testing.T) {
ctx := context.Background()
held := true
builds, rollouts, assignments := NewMemory(), NewMemory(), NewMemory()
c := New(builds, rollouts, assignments, func() bool { return held })
rev, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"})
if err != nil {
t.Fatal(err)
}
held = false
if _, err := c.Builds.Create(ctx, "b2", Build{ID: "b2"}); !errors.Is(err, ErrNotHeld) {
t.Fatalf("a create without the lease answered %v", err)
}
if _, err := c.Builds.Update(ctx, "b1", Build{ID: "b1", Failed: "x"}, rev); !errors.Is(err, ErrNotHeld) {
t.Fatalf("an update without the lease answered %v", err)
}
if _, err := c.Rollouts.Create(ctx, "r", Rollout{ID: "r"}); !errors.Is(err, ErrNotHeld) {
t.Fatalf("a rollout written without the lease: %v", err)
}
if _, err := c.Assignments.Create(ctx, "a.b", Assignment{}); !errors.Is(err, ErrNotHeld) {
t.Fatalf("an assignment written without the lease: %v", err)
}
if builds.Writes() != 1 || rollouts.Writes() != 0 || assignments.Writes() != 0 {
t.Fatalf("writes %d %d %d", builds.Writes(), rollouts.Writes(), assignments.Writes())
}
// Reads still answer.
if _, ok, err := c.Builds.Get(ctx, "b1"); err != nil || !ok {
t.Fatalf("a read without the lease: %v %v", ok, err)
}
if all, err := c.Builds.All(ctx); err != nil || len(all) != 1 {
t.Fatalf("all: %v %v", all, err)
}
// And a controller with no lease function at all holds nothing.
if _, err := New(NewMemory(), NewMemory(), NewMemory(), nil).Builds.Create(ctx, "b", Build{}); !errors.Is(err, ErrNotHeld) {
t.Fatalf("a chain with no lease wrote: %v", err)
}
}
// **A bucket that is away is an error, never an empty bucket** (ADR 0227 rule 4).
func TestABucketThatIsAwayIsAnErrorNotAnEmptyOne(t *testing.T) {
ctx := context.Background()
m := NewMemory()
c := New(m, NewMemory(), NewMemory(), func() bool { return true })
if _, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"}); err != nil {
t.Fatal(err)
}
away := errors.New("the bus is away")
m.SetFail(away)
if all, err := c.Builds.All(ctx); !errors.Is(err, away) || all != nil {
t.Fatalf("all answered %v %v", all, err)
}
if _, _, err := c.Builds.Get(ctx, "b1"); !errors.Is(err, away) {
t.Fatalf("get answered %v", err)
}
}
// A watch hands over every key's value, says it has, and then each write.
func TestAWatchHandsOverTheValuesThenEachWrite(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c := InMemory(nil)
if _, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"}); err != nil {
t.Fatal(err)
}
w, err := c.Builds.Watch(ctx)
if err != nil {
t.Fatal(err)
}
next := func() Entry {
select {
case e := <-w:
return e
case <-ctx.Done():
t.Fatal("the watch said nothing")
return Entry{}
}
}
if e := next(); e.Key != "b1" || e.Caught {
t.Fatalf("first %+v", e)
}
if e := next(); !e.Caught {
t.Fatalf("second %+v", e)
}
if _, err := c.Builds.Create(ctx, "b2", Build{ID: "b2"}); err != nil {
t.Fatal(err)
}
if e := next(); e.Key != "b2" {
t.Fatalf("third %+v", e)
}
}