mesh/delivery-group group fix/1279-consumers-from-now rejected: a member's own check failed
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 677f94e9012b merged into trunk 1325c8b452c8 beside mesh-controller#400@a0c971ce — fail: its merge-check.sh failed: --- FAIL: TestBridg…
mesh/delivery superseded: a newer head of the same pull request
374 lines
14 KiB
Go
374 lines
14 KiB
Go
package index
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
const (
|
|
h1 = "0123456789abcdef0123456789abcdef01234567"
|
|
h2 = "89abcdef0123456789abcdef0123456789abcdef"
|
|
h3 = "fedcba9876543210fedcba9876543210fedcba98"
|
|
)
|
|
|
|
// memStore is the candidates bucket's compare-and-set behaviour in memory: a revision per write, a conflict when the
|
|
// revision read is no longer the key's. Writes counts the writes that went through; beforeWrite lets a test change the
|
|
// key under the indexer.
|
|
type memStore struct {
|
|
vals map[string][]byte
|
|
revs map[string]uint64
|
|
next uint64
|
|
writes int
|
|
beforeWrite 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) {
|
|
v, ok := m.vals[key]
|
|
return v, m.revs[key], ok, nil
|
|
}
|
|
func (m *memStore) Keys(context.Context) ([]string, error) {
|
|
var keys []string
|
|
for k := range m.vals {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys, nil
|
|
}
|
|
func (m *memStore) hook() {
|
|
if h := m.beforeWrite; h != nil {
|
|
m.beforeWrite = nil
|
|
h()
|
|
}
|
|
}
|
|
func (m *memStore) put(key string, value []byte) {
|
|
m.next++
|
|
m.vals[key], m.revs[key] = value, m.next
|
|
}
|
|
func (m *memStore) Create(_ context.Context, key string, value []byte) error {
|
|
m.hook()
|
|
if _, ok := m.vals[key]; ok {
|
|
return ErrConflict
|
|
}
|
|
m.writes++
|
|
m.put(key, value)
|
|
return nil
|
|
}
|
|
func (m *memStore) Update(_ context.Context, key string, value []byte, rev uint64) error {
|
|
m.hook()
|
|
if _, ok := m.vals[key]; !ok || m.revs[key] != rev {
|
|
return ErrConflict
|
|
}
|
|
m.writes++
|
|
m.put(key, value)
|
|
return nil
|
|
}
|
|
func (m *memStore) Delete(_ context.Context, key string, rev uint64) error {
|
|
m.hook()
|
|
if _, ok := m.vals[key]; !ok || m.revs[key] != rev {
|
|
return ErrConflict
|
|
}
|
|
m.writes++
|
|
delete(m.vals, key)
|
|
return nil
|
|
}
|
|
|
|
// buildRecord is the controller's record as mesh-sdk go/delivery/testdata/build-record-build-agent.json (captured from
|
|
// the controller's encoder; copied here, the vendored SDK does not carry testdata) has it, with the repository, module and path changed.
|
|
func buildRecord(t *testing.T, repo, module, path, at string) []byte {
|
|
t.Helper()
|
|
raw := []byte(`{"id":"build-1792395447088936520","state":"built","repository":"novox/mesh-controller","ref":"main","module":"build-agent","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/build-agent","source-fingerprint":"src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7","at":"2026-10-19T09:38:00Z"}`)
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m["repository"], m["module"], m["path"], m["at"] = repo, module, path, at
|
|
b, _ := json.Marshal(m)
|
|
return b
|
|
}
|
|
|
|
func pull(repo string, number int, branch, head string, dirs ...string) delivery.Forge {
|
|
return delivery.Forge{V: delivery.Version, Kind: delivery.ForgePull, Repository: repo, Branch: branch, Number: number, Head: head,
|
|
Action: delivery.PullUpdated, Dirs: dirs, DirsKnown: dirs != nil}
|
|
}
|
|
|
|
func newIndexer(t *testing.T) (*Indexer, *memStore, *strings.Builder) {
|
|
t.Helper()
|
|
st, logs := newMem(), &strings.Builder{}
|
|
b := NewBuilds()
|
|
b.Put(delivery.BuildKey("a", "build-1"), buildRecord(t, "novox/mesh-delivery", "a", "modules/a", "2026-10-19T09:00:00Z"))
|
|
b.Put(delivery.BuildKey("c", "build-2"), buildRecord(t, "novox/mesh-catalog", "c", "modules/c", "2026-10-19T09:00:00Z"))
|
|
b.Put(delivery.BuildKey("odd", "build-3"), buildRecord(t, "novox/mesh-delivery", "odd-name", "modules/odd", "2026-10-19T09:00:00Z"))
|
|
return &Indexer{Store: st, Builds: b, Logf: func(f string, a ...any) {
|
|
logs.WriteString(strings.TrimSpace(strings.ReplaceAll(f, "\n", " ")) + "\n")
|
|
_ = a
|
|
}}, st, logs
|
|
}
|
|
|
|
func read(t *testing.T, st *memStore, key string) (delivery.Candidate, bool) {
|
|
t.Helper()
|
|
v, ok := st.vals[key]
|
|
if !ok {
|
|
return delivery.Candidate{}, false
|
|
}
|
|
c, err := delivery.DecodeCandidate(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c, true
|
|
}
|
|
|
|
func TestTwoRepositoriesFormOneGroupByBranch(t *testing.T) { // 1238/AC2 (the grouping; the bus is the integration test)
|
|
ix, st, _ := newIndexer(t)
|
|
ctx := context.Background()
|
|
if err := ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ix.Apply(ctx, pull("novox/mesh-catalog", 300, "feat/x", h2, "modules/c")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, ok := read(t, st, "feat/x")
|
|
if !ok {
|
|
t.Fatal("no candidate under feat/x")
|
|
}
|
|
hash, _ := delivery.HeadsHash([]delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: h1}, {Repository: "novox/mesh-catalog", Number: 300, Head: h2}})
|
|
if c.Group != "feat/x" || c.Base != 0 || c.HeadsHash != hash || len(c.Pulls) != 2 {
|
|
t.Fatalf("candidate = %+v", c)
|
|
}
|
|
got := map[string][]string{}
|
|
for _, p := range c.Pulls {
|
|
got[p.Repository] = p.Modules
|
|
}
|
|
if strings.Join(got["novox/mesh-delivery"], ",") != "a" || strings.Join(got["novox/mesh-catalog"], ",") != "c" {
|
|
t.Errorf("modules = %v", got)
|
|
}
|
|
}
|
|
|
|
func TestModuleOfADirectory(t *testing.T) { // 1238/AC3
|
|
ix, st, _ := newIndexer(t)
|
|
// modules/odd is named by a build record of the repository (module odd-name); modules/plain is named by none.
|
|
if err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/odd", "modules/plain", "modules/odd")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, _ := read(t, st, "feat/x")
|
|
if strings.Join(c.Pulls[0].Modules, ",") != "odd-name,plain" {
|
|
t.Errorf("modules = %v, want the record's module for modules/odd, the last element for modules/plain, no repeats", c.Pulls[0].Modules)
|
|
}
|
|
// A record of another repository does not name this repository's directory.
|
|
if m, ok := ix.Builds.Module("novox/other", "modules/a"); ok {
|
|
t.Errorf("modules/a of novox/other is %q", m)
|
|
}
|
|
// The newest record of a directory wins.
|
|
ix.Builds.Put(delivery.BuildKey("renamed", "build-9"), buildRecord(t, "novox/mesh-delivery", "renamed", "modules/a", "2026-10-19T10:00:00Z"))
|
|
if m := ix.Builds.ModuleOf("novox/mesh-delivery", "modules/a"); m != "renamed" {
|
|
t.Errorf("modules/a = %q, want the newest record's module", m)
|
|
}
|
|
}
|
|
|
|
func TestDirsNotKnownKeepTheModulesOfTheSameHead(t *testing.T) { // 1238/AC4
|
|
ix, st, _ := newIndexer(t)
|
|
ctx := context.Background()
|
|
_ = ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a"))
|
|
unknown := pull("novox/mesh-delivery", 12, "feat/x", h1)
|
|
if err := ix.Apply(ctx, unknown); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, _ := read(t, st, "feat/x")
|
|
if strings.Join(c.Pulls[0].Modules, ",") != "a" {
|
|
t.Errorf("modules = %v, want a kept", c.Pulls[0].Modules)
|
|
}
|
|
// A new head with directories not known is listed with no modules.
|
|
unknown.Head = h3
|
|
if err := ix.Apply(ctx, unknown); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, _ = read(t, st, "feat/x")
|
|
if c.Pulls[0].Head.Head != h3 || len(c.Pulls[0].Modules) != 0 {
|
|
t.Errorf("pull = %+v, want the new head with no modules", c.Pulls[0])
|
|
}
|
|
// Known and empty is no modules, not unknown.
|
|
known := pull("novox/mesh-delivery", 12, "feat/x", h3)
|
|
known.Dirs, known.DirsKnown = nil, true
|
|
if err := ix.Apply(ctx, known); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestClosedAndMergedTakeAPullOutAndTheLastDeletesTheKey(t *testing.T) { // 1238/AC5
|
|
for _, how := range []string{"closed", "merged"} {
|
|
ix, st, _ := newIndexer(t)
|
|
ctx := context.Background()
|
|
_ = ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a"))
|
|
_ = ix.Apply(ctx, pull("novox/mesh-catalog", 300, "feat/x", h2, "modules/c"))
|
|
gone := func(repo string, n int, head string) delivery.Forge {
|
|
if how == "closed" {
|
|
f := pull(repo, n, "feat/x", head)
|
|
f.Action = delivery.PullClosed
|
|
return f
|
|
}
|
|
return delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: repo, Branch: "feat/x", Number: n, Head: head, Merge: h3, Root: "r", Trees: map[string]string{"a": "t"}}
|
|
}
|
|
if err := ix.Apply(ctx, gone("novox/mesh-delivery", 12, h1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, ok := read(t, st, "feat/x")
|
|
if !ok || len(c.Pulls) != 1 || c.Pulls[0].Repository != "novox/mesh-catalog" {
|
|
t.Fatalf("%s: candidate = %+v (kept %v)", how, c, ok)
|
|
}
|
|
hash, _ := delivery.HeadsHash([]delivery.Head{c.Pulls[0].Head})
|
|
if c.HeadsHash != hash {
|
|
t.Errorf("%s: heads hash %q is not the remaining pull request's %q", how, c.HeadsHash, hash)
|
|
}
|
|
if err := ix.Apply(ctx, gone("novox/mesh-catalog", 300, h2)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := read(t, st, "feat/x"); ok {
|
|
t.Errorf("%s: the last pull request left and the key is still there", how)
|
|
}
|
|
// A pull request the group does not hold changes nothing and writes nothing.
|
|
writes := st.writes
|
|
if err := ix.Apply(ctx, gone("novox/mesh-catalog", 300, h2)); err != nil || st.writes != writes {
|
|
t.Errorf("%s: closing an unheld pull request wrote (%d writes, err %v)", how, st.writes-writes, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrunkLikeBranchesMakeNoCandidate(t *testing.T) { // 1238/AC6
|
|
for _, branch := range []string{"main", "master", "trunk", ""} {
|
|
ix, st, logs := newIndexer(t)
|
|
if err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, branch, h1, "modules/a")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(st.vals) != 0 || st.writes != 0 {
|
|
t.Errorf("branch %q: %d keys written", branch, len(st.vals))
|
|
}
|
|
if !strings.Contains(logs.String(), "no delivery group") {
|
|
t.Errorf("branch %q: log %q does not say why", branch, logs.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSameMessageWritesNoNewRevision(t *testing.T) { // 1238/AC7
|
|
ix, st, _ := newIndexer(t)
|
|
ctx := context.Background()
|
|
m := pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a")
|
|
_ = ix.Apply(ctx, m)
|
|
rev, writes := st.revs["feat/x"], st.writes
|
|
for i := 0; i < 3; i++ {
|
|
if err := ix.Apply(ctx, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if st.revs["feat/x"] != rev || st.writes != writes {
|
|
t.Errorf("the same message wrote again: revision %d to %d", rev, st.revs["feat/x"])
|
|
}
|
|
}
|
|
|
|
func TestALonePullRequestIsAGroupOfOne(t *testing.T) { // 1238/AC8
|
|
ix, st, _ := newIndexer(t)
|
|
if err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, "fix/lone", h1, "modules/a")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, ok := read(t, st, "fix/lone")
|
|
if !ok || c.Group != "fix/lone" || len(c.Pulls) != 1 {
|
|
t.Errorf("candidate = %+v (%v)", c, ok)
|
|
}
|
|
}
|
|
|
|
func TestAConflictReadsAgain(t *testing.T) { // 1238 (compare-and-set: Update with the revision read, read again on a conflict)
|
|
ix, st, _ := newIndexer(t)
|
|
ctx := context.Background()
|
|
_ = ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a"))
|
|
// Between the indexer's read and its write another writer adds #300 to the group.
|
|
st.beforeWrite = func() {
|
|
other := delivery.Candidate{V: delivery.Version, Group: "feat/x", Pulls: []delivery.CandidatePull{
|
|
{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: h1}, Modules: []string{"a"}},
|
|
{Head: delivery.Head{Repository: "novox/mesh-catalog", Number: 300, Head: h2}, Modules: []string{"c"}}}}
|
|
other.HeadsHash, _ = delivery.HeadsHash([]delivery.Head{other.Pulls[0].Head, other.Pulls[1].Head})
|
|
data, _ := delivery.EncodeCandidate(other)
|
|
st.put("feat/x", data)
|
|
}
|
|
if err := ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h3, "modules/a")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, _ := read(t, st, "feat/x")
|
|
if len(c.Pulls) != 2 {
|
|
t.Fatalf("pulls = %+v, want #12 moved and #300 kept (the other writer's)", c.Pulls)
|
|
}
|
|
for _, p := range c.Pulls {
|
|
if p.Number == 12 && p.Head.Head != h3 {
|
|
t.Errorf("#12 head = %s", p.Head.Head)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnUnusableBranchNameIsUnreadable(t *testing.T) {
|
|
ix, _, _ := newIndexer(t)
|
|
err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, "feat x?", h1, "modules/a"))
|
|
if err == nil || !strings.Contains(err.Error(), "unreadable") {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
}
|
|
|
|
func open(pulls ...delivery.OpenPull) delivery.Forge {
|
|
return delivery.Forge{V: delivery.Version, Kind: delivery.ForgeOpen, Open: pulls}
|
|
}
|
|
|
|
// 1279/AC7 (ADR 0443 §5): on an open message, candidates lose every pull request it does not name, a group left empty is
|
|
// deleted, and a pull request added by a later message stays.
|
|
func TestAnOpenMessageDropsEveryCandidateItDoesNotName(t *testing.T) { // 1279/AC7
|
|
ix, st, _ := newIndexer(t)
|
|
ctx := context.Background()
|
|
for _, f := range []delivery.Forge{
|
|
pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a"), pull("novox/mesh-catalog", 300, "feat/x", h2, "modules/c"),
|
|
pull("novox/mesh-delivery", 13, "feat/y", h2, "modules/a"),
|
|
pull("novox/mesh-catalog", 280, "feat/old", h3, "modules/c"),
|
|
} {
|
|
if err := ix.Apply(ctx, f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
before, _ := read(t, st, "feat/x")
|
|
|
|
// Open: mesh-delivery#12 only (and a pull request no group holds, which changes nothing).
|
|
if err := ix.Apply(ctx, open(delivery.OpenPull{Repository: "novox/mesh-delivery", Number: 12}, delivery.OpenPull{Repository: "novox/mesh-catalog", Number: 999})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, ok := read(t, st, "feat/x")
|
|
hash, _ := delivery.HeadsHash([]delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: h1}})
|
|
if !ok || len(c.Pulls) != 1 || c.Pulls[0].Number != 12 || c.Pulls[0].Head.Head != h1 || strings.Join(c.Pulls[0].Modules, ",") != "a" || c.HeadsHash != hash || c.HeadsHash == before.HeadsHash {
|
|
t.Fatalf("feat/x after the open message = %+v, want only mesh-delivery#12 with its modules and the heads hash of it alone", c)
|
|
}
|
|
for _, gone := range []string{"feat/y", "feat/old"} {
|
|
if _, ok := st.vals[gone]; ok {
|
|
t.Errorf("the group %s is left with no open pull request and was not deleted", gone)
|
|
}
|
|
}
|
|
|
|
// A pull request opened after the read is heard as a message after the open message and stays.
|
|
if err := ix.Apply(ctx, pull("novox/mesh-catalog", 300, "feat/x", h2, "modules/c")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c, _ := read(t, st, "feat/x"); len(c.Pulls) != 2 {
|
|
t.Errorf("a pull request added after the open message is not kept: %+v", c)
|
|
}
|
|
// The same open message again changes nothing (no write), and an empty one drops every group.
|
|
writes := st.writes
|
|
if err := ix.Apply(ctx, open(delivery.OpenPull{Repository: "novox/mesh-delivery", Number: 12}, delivery.OpenPull{Repository: "novox/mesh-catalog", Number: 300})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.writes != writes {
|
|
t.Errorf("an open message that names every candidate wrote %d times", st.writes-writes)
|
|
}
|
|
if err := ix.Apply(ctx, open()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(st.vals) != 0 {
|
|
t.Errorf("after an empty open message the candidates hold %v", st.vals)
|
|
}
|
|
}
|