Files
mesh-delivery/modules/mesh-delivery-builder/internal/build/claim_test.go
T
jschoubben 64b918c64c DLV-B1, DLV-B2: the builder's state versions, numbering under compare-and-set per fingerprint or source revision, and builder_versions
The builder keeps one key per name, tag and version (design 51 *The versions*, ADR 0606), in its old state
section (ADR 0619), history one, 64 MiB with the 80 % mark. A package is numbered per source revision and keeps a
version held published, publishing nothing; one numbered but never stored is published again under it. An image,
bundle or archive keeps its fingerprint's number. An unknown floor builds an image, bundle or archive unnumbered and
makes a package wait (ADR 0617 §1). Merge order and tagged versions below the next version are the package's
helpers. builder_versions answers a name's versions and the holder's question (ADR 0605 §4).

Four claim tests merged into two, so the new table test adds no function (test budget).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:39:17 +01:00

218 lines
8.2 KiB
Go

package build
import (
"context"
"errors"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// memStore is a bucket's create and compare-and-set in memory, with a revision per write.
type memStore struct {
mu sync.Mutex
vals map[string][]byte
revs map[string]uint64
next uint64
writes int
// before runs once ahead of the next write, so a test changes the key under the writer.
before func()
}
func newMem() *memStore { return &memStore{vals: map[string][]byte{}, revs: map[string]uint64{}} }
func (m *memStore) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.vals[key]
return v, m.revs[key], ok, nil
}
func (m *memStore) Keys(_ context.Context) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
var keys []string
for k := range m.vals {
keys = append(keys, k)
}
return keys, nil
}
func (m *memStore) hook() {
if h := m.before; h != nil {
m.before = nil
h()
}
}
func (m *memStore) put(key string, value []byte) uint64 {
m.next++
m.writes++
m.vals[key], m.revs[key] = value, m.next
return m.next
}
func (m *memStore) Create(_ context.Context, key string, value []byte) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.hook()
if _, ok := m.vals[key]; ok {
return 0, ErrConflict
}
return m.put(key, value), nil
}
func (m *memStore) Update(_ context.Context, key string, value []byte, rev uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.hook()
if _, ok := m.vals[key]; !ok || m.revs[key] != rev {
return 0, ErrConflict
}
return m.put(key, value), nil
}
const (
fp1 = "src2:1111111111111111111111111111111111111111111111111111111111111111"
digest = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
)
func seed(t *testing.T, st *memStore, b delivery.BuilderBuild) string {
t.Helper()
key, err := delivery.BuilderBuildKey("m", fp1)
if err != nil {
t.Fatal(err)
}
data, err := delivery.EncodeBuilderBuild(b)
if err != nil {
t.Fatal(err)
}
st.put(key, data)
return key
}
func valueOf(t *testing.T, st *memStore, key string) delivery.BuilderBuild {
t.Helper()
b, err := delivery.DecodeBuilderBuild(st.vals[key])
if err != nil {
t.Fatal(err)
}
return b
}
func TestClaimCreatesTheKeyBuildingAndASecondBuilderGetsNothing(t *testing.T) { // 1607/AC3
st := newMem()
a, b := Claimer{Store: st, By: "novox.a"}, Claimer{Store: st, By: "novox.b"}
got, err := a.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{})
if err != nil || got == nil {
t.Fatalf("first claim = %v, %v, want a claim", got, err)
}
if v := valueOf(t, st, got.Key); v.State != delivery.BuildBuilding || v.By != "novox.a" || v.Manifest != hex64('a') {
t.Errorf("the key holds %+v, want building by novox.a with the manifest digest", v)
}
if second, err := b.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}); err != nil || second != nil {
t.Errorf("second builder's claim = %v, %v, want none: the same source is never built twice", second, err)
}
// A claim losing the create race gets nothing (merged into this test for the test budget, design 51).
st = newMem()
st.before = func() { seed(t, st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.b"}) }
got, err = Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
if err != nil || got != nil {
t.Errorf("claim = %v, %v, want none: the create failed because the key exists", got, err)
}
}
func TestADoneKeyIsNeverTakenAgain(t *testing.T) { // 1607/AC3; a key whose artifacts name references (ADR 0498 clause 8)
st := newMem()
seed(t, st, delivery.BuilderBuild{State: delivery.BuildDone, By: "novox.a", Manifest: hex64('a'), Artifacts: []delivery.BuiltArtifact{{Name: "x", Kind: "bundle", Digest: digest, Reference: "artifact-store://m/x/blobs/" + digest}}})
writes := st.writes
got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{})
if err != nil || got != nil || st.writes != writes {
t.Errorf("claim = %v, %v, writes %d (was %d), want none and no write", got, err, st.writes, writes)
}
}
func TestAFailedKeyIsTakenAgainAsANewRevisionExceptARefusedKind(t *testing.T) { // 1607/AC3, ADR 0478 clause 2
st := newMem()
key := seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: "the step failed"})
rev := st.revs[key]
got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{})
if err != nil || got == nil || st.revs[key] <= rev {
t.Fatalf("claim = %v, %v, revision %d (was %d), want a claim as a new revision of the same key", got, err, st.revs[key], rev)
}
if v := valueOf(t, st, key); v.State != delivery.BuildBuilding || v.Error != "" {
t.Errorf("the key holds %+v, want building with no error", v)
}
st = newMem()
key = seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: delivery.RefusedKind + "package of language \"python\" (artifact x)"})
writes := st.writes
if got, err := (Claimer{Store: st, By: "novox.a"}).Claim(context.Background(), "m", fp1, "", ClaimOptions{}); err != nil || got != nil || st.writes != writes {
t.Errorf("a refused kind: claim = %v, %v, writes %d (was %d), want none and no write", got, err, st.writes, writes)
}
for _, e := range []string{delivery.NotPinned + "up: nats:2 names no digest", delivery.VersionTaken + "@novox/mesh-sdk@0.1.15"} { // B3, B5: ADR 0498 clauses 4, 5
st = newMem()
seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: e})
writes = st.writes
if got, err := (Claimer{Store: st, By: "novox.a"}).Claim(context.Background(), "m", fp1, "", ClaimOptions{}); err != nil || got != nil || st.writes != writes {
t.Errorf("%s: claim = %v, %v, writes %d (was %d), want none until the fingerprint changes", e, got, err, st.writes, writes)
}
}
}
func TestAKeyFailedForNoImageBuilderIsTakenAgainOnlyWhenANodeHasTheModule(t *testing.T) { // 1705b
st := newMem()
key := seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: NoImageBuilderPrefix + " no node has mesh-build-image"})
writes := st.writes
c := Claimer{Store: st, By: "novox.a"}
for _, opt := range []ClaimOptions{{}, {ImageBuilder: func() bool { return false }}} {
if got, err := c.Claim(context.Background(), "m", fp1, "", opt); err != nil || got != nil || st.writes != writes {
t.Errorf("claim = %v, %v, writes %d (was %d), want none and no write", got, err, st.writes, writes)
}
}
got, err := c.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{ImageBuilder: func() bool { return true }})
if err != nil || got == nil || valueOf(t, st, key).State != delivery.BuildBuilding {
t.Errorf("claim = %v, %v, want a claim with the key building", got, err)
}
}
func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing.T) { // 1607/AC3
st := newMem()
c := Claimer{Store: st, By: "novox.a"}
got, _ := c.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{})
done := delivery.BuilderBuild{State: delivery.BuildDone, By: "novox.a", Manifest: hex64('a'), Artifacts: []delivery.BuiltArtifact{{Name: "x", Kind: "archive", Digest: digest}}}
if err := c.Finish(context.Background(), got, done); err != nil {
t.Fatal(err)
}
if v := valueOf(t, st, got.Key); v.State != delivery.BuildDone || v.Artifacts[0].Digest != digest {
t.Errorf("the key holds %+v", v)
}
writes := st.writes
if err := c.Finish(context.Background(), got, done); err != nil && !errors.Is(err, ErrConflict) {
t.Fatal(err)
}
if st.writes != writes {
t.Errorf("the same value was written again (%d writes, was %d)", st.writes, writes)
}
// Finish after the key changed is a conflict, not an overwrite (merged into this test for the test budget).
st = newMem()
c = Claimer{Store: st, By: "novox.a"}
got, _ = c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
st.put(got.Key, []byte(`{"v":1,"state":"failed","error":"someone else"}`))
err := c.Finish(context.Background(), got, delivery.BuilderBuild{State: delivery.BuildFailed, Error: "mine"})
if !errors.Is(err, ErrConflict) {
t.Errorf("Finish = %v, want ErrConflict", err)
}
}
func hex64(c byte) string {
b := make([]byte, 64)
for i := range b {
b[i] = c
}
return string(b)
}