761 lines
32 KiB
Go
761 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// The suite's tests of a merge, but for the merge window's own (this file's), plan the merge as it is heard: a
|
|
// window of nothing closes at once, so a merge with no walk open is cut into its walk at once, as every merge
|
|
// was planned before novox/hq ADR 0276. The window's tests set it through the controller's settings.
|
|
func init() { mergeWindowDefault = 0 }
|
|
|
|
// windowed is a mesh whose controller's settings give a merge window of 90 seconds and at most 10 minutes, as its
|
|
// settings file says them, and
|
|
// modules built from the catalogue (app, notes) and from three repositories of their own.
|
|
func windowed(t *testing.T) *stores {
|
|
t.Helper()
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
settings := t.TempDir() + "/merge-window.json"
|
|
if err := os.WriteFile(settings, []byte(`{"merge-window": "90s", "merge-window-at-most": "10m"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(mergeWindowFileVar, settings)
|
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"},
|
|
inventory.Source{Repository: "novox/mesh-controller", Seat: "git", Ref: "main", BuiltFrom: "c0",
|
|
Head: "c0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, m := range []struct{ module, repository, path string }{
|
|
{"app", "novox/mesh-catalog", "modules/app"},
|
|
{"notes", "novox/mesh-catalog", "modules/notes"},
|
|
{"one", "novox/one", ""},
|
|
{"two", "novox/two", ""},
|
|
{"three", "novox/three", ""},
|
|
} {
|
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m.module, Version: "1"},
|
|
inventory.Source{Repository: m.repository, Seat: "git", Path: m.path, Ref: "main", BuiltFrom: "c0",
|
|
Head: "c0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return open
|
|
}
|
|
|
|
// t0 is the moment a test's clock starts: merges are dated by it, and heard after it.
|
|
var t0 = time.Now().UTC().Truncate(time.Second)
|
|
|
|
// catalogueMerge is a merge of the catalogue changing one module's directory, made at a moment.
|
|
func catalogueMerge(commit, module string, made time.Time) link.SourceMoved {
|
|
return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
|
|
MergedAt: made.Format(time.RFC3339Nano), Paths: []string{"modules/" + module + "/module.json"},
|
|
ModuleDirs: []string{"modules/" + module}, ModuleDirsSaid: true}
|
|
}
|
|
|
|
// repoMerge is a merge of a repository of one module's own.
|
|
func repoMerge(repo, commit string, made time.Time) link.SourceMoved {
|
|
return link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Commit: commit,
|
|
MergedAt: made.Format(time.RFC3339Nano), Paths: []string{"main.go"}}
|
|
}
|
|
|
|
// hear is a merge heard at a moment.
|
|
func hear(t *testing.T, open *stores, m link.SourceMoved, now time.Time) {
|
|
t.Helper()
|
|
if err := (following{open: open}).hearMerge(t.Context(), m, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// cutAt is the cutter looking at a moment.
|
|
func cutAt(t *testing.T, open *stores, now time.Time) {
|
|
t.Helper()
|
|
if err := cutBatchesHeld(t.Context(), open, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// walks is every plan record that is a walk, newest first, and the batches not yet cut.
|
|
func walks(t *testing.T, open *stores) (walks, batches []inventory.Plan) {
|
|
t.Helper()
|
|
recent, err := open.inventory.RecentPlans(t.Context(), 50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range recent {
|
|
if p.Batch() {
|
|
batches = append(batches, p)
|
|
} else {
|
|
walks = append(walks, p)
|
|
}
|
|
}
|
|
return walks, batches
|
|
}
|
|
|
|
// The replay of issue 362: two catalogue merges 18 seconds apart are one batch, cut once into one walk at the
|
|
// later commit, which names both merges — the earlier carried by the later.
|
|
func TestTwoMergesSecondsApartAreOneWalkAtTheLaterCommit(t *testing.T) {
|
|
open := windowed(t)
|
|
asked := asksWithPaths(t)
|
|
claude := catalogueMerge("553b7191claude", "app", t0)
|
|
dunst := catalogueMerge("48bda475dunst", "notes", t0.Add(17*time.Second))
|
|
hear(t, open, claude, t0.Add(time.Second))
|
|
hear(t, open, dunst, t0.Add(18*time.Second))
|
|
if ws, bs := walks(t, open); len(ws) != 0 || len(bs) != 1 {
|
|
t.Fatalf("within the window: %d walk(s), %d batch(es)", len(ws), len(bs))
|
|
}
|
|
cutAt(t, open, t0.Add(18*time.Second+89*time.Second))
|
|
if ws, _ := walks(t, open); len(ws) != 0 {
|
|
t.Fatalf("cut before the window closed: %+v", ws)
|
|
}
|
|
cutAt(t, open, t0.Add(18*time.Second+90*time.Second))
|
|
ws, bs := walks(t, open)
|
|
if len(ws) != 1 || len(bs) != 0 {
|
|
t.Fatalf("after the window: %d walk(s), %d batch(es)", len(ws), len(bs))
|
|
}
|
|
w := ws[0]
|
|
if w.Commit != dunst.Commit || len(w.Commits) != 1 || w.Commits[0].Commit != dunst.Commit {
|
|
t.Fatalf("the walk is at %s %+v, not the later commit", w.Commit, w.Commits)
|
|
}
|
|
for _, m := range []string{"app", "notes"} {
|
|
if _, in := w.Modules[m]; !in {
|
|
t.Fatalf("the walk does not build %s, which one of its merges moved: %v", m, w.Modules)
|
|
}
|
|
}
|
|
want := []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: claude.Commit, Carried: dunst.Commit},
|
|
{Repository: "novox/mesh-catalog", Commit: dunst.Commit}}
|
|
if w.Delivery == nil || !slices.Equal(w.Delivery.Merges, want) {
|
|
t.Fatalf("the walk answers %+v, want %+v", w.Delivery, want)
|
|
}
|
|
if len(*asked) != 2 || (*asked)[0][2] != "main" || (*asked)[1][2] != "main" {
|
|
t.Fatalf("the walk did not ask its modules at the branch, which holds the commit it carries: %v", *asked)
|
|
}
|
|
// Heard again, as the bus may hand it over twice: never a second merge, nor a second walk.
|
|
hear(t, open, claude, t0.Add(5*time.Minute))
|
|
if ws, bs := walks(t, open); len(ws) != 1 || len(bs) != 0 {
|
|
t.Fatalf("a merge heard twice made %d walk(s) and %d batch(es)", len(ws), len(bs))
|
|
}
|
|
}
|
|
|
|
// Merges of three repositories in one window are one walk, one commit for each.
|
|
func TestThreeRepositoriesInOneWindowAreOneWalk(t *testing.T) {
|
|
open := windowed(t)
|
|
asksRecorded(t)
|
|
for i, r := range []string{"one", "two", "three"} {
|
|
hear(t, open, repoMerge(r, "c-"+r, t0.Add(time.Duration(i)*20*time.Second)),
|
|
t0.Add(time.Duration(i)*20*time.Second+time.Second))
|
|
}
|
|
cutAt(t, open, t0.Add(41*time.Second+90*time.Second))
|
|
ws, bs := walks(t, open)
|
|
if len(ws) != 1 || len(bs) != 0 {
|
|
t.Fatalf("%d walk(s), %d batch(es)", len(ws), len(bs))
|
|
}
|
|
w := ws[0]
|
|
if len(w.Commits) != 3 || len(w.Delivery.Merges) != 3 {
|
|
t.Fatalf("the walk carries %+v and answers %+v", w.Commits, w.Delivery.Merges)
|
|
}
|
|
for _, r := range []string{"one", "two", "three"} {
|
|
if w.CommitOf("novox/"+r) != "c-"+r {
|
|
t.Fatalf("the walk carries %s at %q", r, w.CommitOf("novox/"+r))
|
|
}
|
|
if _, in := w.Modules[r]; !in {
|
|
t.Fatalf("the walk does not build %s: %v", r, w.Modules)
|
|
}
|
|
}
|
|
// Each module's ask is recorded at its own repository's commit.
|
|
for _, r := range []string{"one", "two", "three"} {
|
|
q, found, err := open.inventory.BuildRequestByID(t.Context(), w.Modules[r].Build)
|
|
if err != nil || !found || q.Commit != "c-"+r {
|
|
t.Fatalf("%s's build was not recorded at its own commit: %+v %v %v", r, q, found, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Each merge restarts the window: a merge at second 80 keeps the batch open until second 170; the window
|
|
// closes at most ten minutes after its first merge however busy.
|
|
func TestAMergeRestartsTheWindowAndTheMaximumClosesIt(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
hear(t, open, repoMerge("one", "c1", t0), t0)
|
|
hear(t, open, repoMerge("two", "c2", t0.Add(80*time.Second)), t0.Add(80*time.Second))
|
|
cutAt(t, open, t0.Add(100*time.Second))
|
|
if ws, _ := walks(t, open); len(ws) != 0 {
|
|
t.Fatal("the window closed 90 seconds after its first merge, not after its last")
|
|
}
|
|
cutAt(t, open, t0.Add(170*time.Second))
|
|
if ws, _ := walks(t, open); len(ws) != 1 || len(ws[0].Commits) != 2 {
|
|
t.Fatalf("the window did not close 90 seconds after its last merge: %+v", ws)
|
|
}
|
|
|
|
busy := windowed(t)
|
|
asksWithPaths(t)
|
|
var last time.Time
|
|
for i := 0; i < 12; i++ {
|
|
last = t0.Add(time.Duration(i) * time.Minute)
|
|
repo := []string{"one", "two", "three"}[i%3]
|
|
hear(t, busy, repoMerge(repo, "c"+string(rune('a'+i)), last), last)
|
|
if ws, _ := walks(t, busy); len(ws) > 0 {
|
|
if i != 10 {
|
|
t.Fatalf("a busy window closed at merge %d (minute %d), not at its maximum", i, i)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
ws, _ := walks(t, busy)
|
|
if len(ws) != 1 {
|
|
t.Fatalf("ten minutes of merges a minute apart were never cut: %d walk(s)", len(ws))
|
|
}
|
|
}
|
|
|
|
// `plans` lists the batch being assembled first, in the operator's words: how long is left, when at the latest,
|
|
// what is grouped, and that the plan is not yet calculated. It says so on the bus as plan-moved, too.
|
|
func TestTheAssemblingBatchIsShown(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
var said []inventory.Plan
|
|
was := inventory.PlanSaved
|
|
inventory.PlanSaved = func(p inventory.Plan) { said = append(said, p) }
|
|
t.Cleanup(func() { inventory.PlanSaved = was })
|
|
now := time.Now().UTC()
|
|
hear(t, open, catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second)), now.Add(-19*time.Second))
|
|
hear(t, open, catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second)), now.Add(-time.Second))
|
|
hear(t, open, repoMerge("one", "a6bc0931one", now), now)
|
|
out := captured(t, func() error { return plansCommand(t.Context(), nil) })
|
|
first := strings.SplitN(out, "\n", 2)[0]
|
|
for _, want := range []string{"assembling: ", " s left (at the latest ", "grouped: novox/mesh-catalog@48bda475 " +
|
|
"(answers 553b7191), novox/one@a6bc0931; plan not yet calculated"} {
|
|
if !strings.Contains(first, want) {
|
|
t.Fatalf("plans' first line %q does not say %q", first, want)
|
|
}
|
|
}
|
|
if len(said) == 0 || said[len(said)-1].State != inventory.PlanAssembling || said[len(said)-1].Delivery.Batch == nil ||
|
|
len(said[len(said)-1].Delivery.Merges) != 3 {
|
|
t.Fatalf("the batch was not said as assembling with its merges: %+v", said)
|
|
}
|
|
}
|
|
|
|
// captured is what a command printed.
|
|
func captured(t *testing.T, run func() error) string {
|
|
t.Helper()
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
was := os.Stdout
|
|
os.Stdout = w
|
|
runErr := run()
|
|
os.Stdout = was
|
|
_ = w.Close()
|
|
var b bytes.Buffer
|
|
_, _ = io.Copy(&b, r)
|
|
if runErr != nil {
|
|
t.Fatal(runErr)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// One walk at a time: a merge heard while a walk runs joins the next batch, which is cut when the walk ends;
|
|
// the walk that started is never superseded.
|
|
func TestAMergeDuringAWalkJoinsTheNextBatch(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
hear(t, open, repoMerge("one", "c1", t0), t0)
|
|
cutAt(t, open, t0.Add(90*time.Second))
|
|
ws, _ := walks(t, open)
|
|
if len(ws) != 1 || !ws[0].Open() {
|
|
t.Fatalf("the first batch was not cut: %+v", ws)
|
|
}
|
|
first := ws[0]
|
|
hear(t, open, repoMerge("two", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
|
|
cutAt(t, open, t0.Add(5*time.Minute))
|
|
ws, bs := walks(t, open)
|
|
if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID {
|
|
t.Fatalf("the merge during the walk: %d walk(s), batches %+v", len(ws), bs)
|
|
}
|
|
if got, _ := open.inventory.PlanByID(t.Context(), first.ID); !got.Open() {
|
|
t.Fatalf("the started walk was %s by the next merge", got.State)
|
|
}
|
|
if !strings.HasPrefix(batchWords(bs[0], t0.Add(5*time.Minute)), "queued behind "+first.ID) {
|
|
t.Fatalf("a queued batch reads %q", batchWords(bs[0], t0.Add(5*time.Minute)))
|
|
}
|
|
first.State = inventory.PlanDone
|
|
if err := open.inventory.SavePlan(t.Context(), &first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cutAt(t, open, t0.Add(6*time.Minute))
|
|
ws, bs = walks(t, open)
|
|
if len(ws) != 2 || len(bs) != 0 || ws[0].Commit != "c2" {
|
|
t.Fatalf("the queued batch was not cut when the walk ended: %+v %+v", ws, bs)
|
|
}
|
|
}
|
|
|
|
// A walk waiting for its delivery's word is folded into the next batch's walk, which answers its merges; it
|
|
// names the walk that took it over.
|
|
func TestAWaitingWalkIsFoldedIntoTheNextBatch(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
ctx := t.Context()
|
|
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-delivery", Version: "1",
|
|
Claims: []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}},
|
|
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/mesh-delivery", Ref: "main",
|
|
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hear(t, open, repoMerge("one", "c1", t0), t0)
|
|
cutAt(t, open, t0.Add(90*time.Second))
|
|
ws, _ := walks(t, open)
|
|
if len(ws) != 1 || !ws[0].Waiting() {
|
|
t.Fatalf("the walk does not wait for its word: %+v", ws)
|
|
}
|
|
waiting := ws[0]
|
|
hear(t, open, repoMerge("two", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
|
|
cutAt(t, open, t0.Add(2*time.Minute+90*time.Second))
|
|
ws, bs := walks(t, open)
|
|
if len(bs) != 0 || len(ws) != 2 {
|
|
t.Fatalf("%d walk(s), %d batch(es)", len(ws), len(bs))
|
|
}
|
|
folded, err := open.inventory.PlanByID(ctx, waiting.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newer := ws[0]
|
|
if folded.State != inventory.PlanSuperseded || folded.Delivery.TakenOverBy != newer.ID {
|
|
t.Fatalf("the waiting walk is %s, taken over by %q", folded.State, folded.Delivery.TakenOverBy)
|
|
}
|
|
if len(newer.Delivery.Merges) != 2 || newer.CommitOf("novox/one") != "c1" || newer.CommitOf("novox/two") != "c2" {
|
|
t.Fatalf("the newer walk answers %+v", newer.Delivery.Merges)
|
|
}
|
|
if _, in := newer.Modules["one"]; !in {
|
|
t.Fatalf("what the folded walk was to build is not built: %v", newer.Modules)
|
|
}
|
|
}
|
|
|
|
// A merge heard after a later merge of its branch was walked is answered by that walk when it builds all it
|
|
// moves — named at once on a walk done — and joins the next batch when it does not.
|
|
func TestALateMergeIsAnsweredByTheWalkOfTheLaterOne(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
ctx := t.Context()
|
|
later := catalogueMerge("48bda475dunst", "notes", t0.Add(17*time.Second))
|
|
hear(t, open, later, t0.Add(18*time.Second))
|
|
cutAt(t, open, t0.Add(2*time.Minute))
|
|
ws, _ := walks(t, open)
|
|
w := ws[0]
|
|
w.State = inventory.PlanDone
|
|
if err := open.inventory.SavePlan(ctx, &w); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Moves only notes, which the done walk built from the branch after it: answered there, at once.
|
|
hear(t, open, catalogueMerge("553b7191early", "notes", t0), t0.Add(15*time.Minute))
|
|
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
|
if got.State != inventory.PlanDone || len(got.Delivery.Merges) != 2 ||
|
|
got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early",
|
|
Carried: later.Commit}) {
|
|
t.Fatalf("the late merge is not named by the walk that carried it: %+v", got.Delivery.Merges)
|
|
}
|
|
// Moves app, which that walk never built: the next batch walks it.
|
|
hear(t, open, catalogueMerge("1111aaaaapp", "app", t0.Add(time.Second)), t0.Add(16*time.Minute))
|
|
_, bs := walks(t, open)
|
|
if len(bs) != 1 || bs[0].Delivery.Merges[0].Commit != "1111aaaaapp" {
|
|
t.Fatalf("a late merge moving what the walk never built did not join the next batch: %+v", bs)
|
|
}
|
|
}
|
|
|
|
// A failed walk marks nothing delivered: its earlier merges are walked alone, on their own commit, the newest
|
|
// first; the first delivered answers the older ones, and the search stops.
|
|
func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) {
|
|
open := windowed(t)
|
|
asked := asksWithPaths(t)
|
|
ctx := t.Context()
|
|
// Made before the cut marks their modules seen, as merges are: walked again, they are news all the same.
|
|
oldest := catalogueMerge("aaaa0001", "app", t0.Add(-3*time.Minute))
|
|
middle := catalogueMerge("bbbb0002", "app", t0.Add(-3*time.Minute+10*time.Second))
|
|
newest := catalogueMerge("cccc0003", "notes", t0.Add(-3*time.Minute+20*time.Second))
|
|
for i, m := range []link.SourceMoved{oldest, middle, newest} {
|
|
hear(t, open, m, t0.Add(time.Duration(i)*10*time.Second+time.Second))
|
|
}
|
|
cutAt(t, open, t0.Add(2*time.Minute))
|
|
ws, _ := walks(t, open)
|
|
failed := ws[0]
|
|
failed.State, failed.Note = inventory.PlanFailed, "notes failed to build in tier 0"
|
|
if err := open.inventory.SavePlan(ctx, &failed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cutAt(t, open, t0.Add(3*time.Minute))
|
|
got, _ := open.inventory.PlanByID(ctx, failed.ID)
|
|
if got.State != inventory.PlanFailed || len(got.Delivery.Merges) != 3 {
|
|
t.Fatalf("the failed walk's record changed: %s %+v", got.State, got.Delivery.Merges)
|
|
}
|
|
ws, _ = walks(t, open)
|
|
alone := ws[0]
|
|
if alone.ID == failed.ID || alone.Commit != middle.Commit || len(alone.Delivery.Merges) != 1 ||
|
|
alone.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) {
|
|
t.Fatalf("the newest earlier merge was not walked alone on its own commit: %+v", alone)
|
|
}
|
|
if _, in := alone.Modules["app"]; !in || (*asked)[len(*asked)-1] != [3]string{"novox/mesh-catalog", "modules/app",
|
|
middle.Commit} {
|
|
t.Fatalf("the merge walked alone does not build app at its own commit: %v, asked %v", alone.Modules, *asked)
|
|
}
|
|
// Retried, the failed walk would name merges the search answers now.
|
|
if _, err := retryPlan(ctx, open, failed.ID); err == nil || !strings.Contains(err.Error(), "walked alone") {
|
|
t.Fatalf("a failed walk whose merges are searched was retried: %v", err)
|
|
}
|
|
alone.State = inventory.PlanDone
|
|
if err := open.inventory.SavePlan(ctx, &alone); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cutAt(t, open, t0.Add(4*time.Minute))
|
|
ws, bs := walks(t, open)
|
|
if ws[0].ID != alone.ID || len(bs) != 0 {
|
|
t.Fatalf("the search went on after a merge was delivered: %+v", ws[0])
|
|
}
|
|
done, _ := open.inventory.PlanByID(ctx, alone.ID)
|
|
if len(done.Delivery.Merges) != 2 || done.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog",
|
|
Commit: oldest.Commit, Carried: middle.Commit}) {
|
|
t.Fatalf("the oldest merge is not answered by the walk that delivered the one after it: %+v", done.Delivery.Merges)
|
|
}
|
|
// A stopped walk starts no search: a person ended it.
|
|
hear(t, open, catalogueMerge("dddd0004", "app", t0.Add(5*time.Minute)), t0.Add(5*time.Minute))
|
|
hear(t, open, catalogueMerge("eeee0005", "notes", t0.Add(5*time.Minute+time.Second)), t0.Add(5*time.Minute+time.Second))
|
|
cutAt(t, open, t0.Add(8*time.Minute))
|
|
ws, _ = walks(t, open)
|
|
if _, err := stopWalk(ctx, open.inventory, ws[0].ID, "mesh-delivery for jochen", "not now"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cutAt(t, open, t0.Add(9*time.Minute))
|
|
if again, _ := walks(t, open); again[0].ID != ws[0].ID {
|
|
t.Fatalf("a stopped walk's earlier merge was walked alone: %+v", again[0])
|
|
}
|
|
}
|
|
|
|
// A delivery group's order holds inside the walk (ADR 0249 unchanged): merges of two repositories from one head
|
|
// branch name, the node-engine's before the controller's, are tiered so; without the group, one tier.
|
|
func TestAGroupsOrderBecomesTiersInsideTheWalk(t *testing.T) {
|
|
for _, grouped := range []bool{true, false} {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
ctx := t.Context()
|
|
for _, m := range []string{"mesh-host"} {
|
|
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"},
|
|
inventory.Source{Repository: "novox/" + m, Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
host := repoMerge("mesh-host", "h1", t0)
|
|
ctl := repoMerge("mesh-controller", "k1", t0.Add(time.Second))
|
|
host.Head, ctl.Head = "feat/together", "feat/together"
|
|
if !grouped {
|
|
ctl.Head = "feat/alone"
|
|
}
|
|
hear(t, open, ctl, t0.Add(2*time.Second))
|
|
hear(t, open, host, t0.Add(3*time.Second))
|
|
cutAt(t, open, t0.Add(2*time.Minute))
|
|
ws, _ := walks(t, open)
|
|
if len(ws) != 1 {
|
|
t.Fatalf("%d walks", len(ws))
|
|
}
|
|
tiers := ws[0].Tiers
|
|
if grouped {
|
|
if len(tiers) != 2 || !slices.Equal(tiers[0], []string{"mesh-host"}) ||
|
|
!slices.Equal(tiers[1], []string{"mesh-controller"}) {
|
|
t.Fatalf("the group's order is not the walk's tiers: %v", tiers)
|
|
}
|
|
} else if len(tiers) != 1 {
|
|
t.Fatalf("two merges of no group were ordered: %v", tiers)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A restarted controller resumes the window where it stood: the batch, its merges and their times are in the
|
|
// store, a merge handed over again is not doubled, and the batch is cut when its window closes.
|
|
func TestABatchSurvivesARestart(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
hear(t, open, repoMerge("one", "c1", t0), t0)
|
|
hear(t, open, repoMerge("two", "c2", t0.Add(30*time.Second)), t0.Add(30*time.Second))
|
|
|
|
// A new controller: nothing of the first one's but the store.
|
|
again, err := openStores(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(again.Close)
|
|
hear(t, again, repoMerge("one", "c1", t0), t0.Add(60*time.Second)) // the bus hands it over again
|
|
cutAt(t, again, t0.Add(119*time.Second))
|
|
if ws, bs := walks(t, again); len(ws) != 0 || len(bs) != 1 || len(bs[0].Delivery.Merges) != 2 {
|
|
t.Fatalf("the restarted controller's batch: %d walk(s), %+v", len(ws), bs)
|
|
}
|
|
cutAt(t, again, t0.Add(120*time.Second))
|
|
ws, bs := walks(t, again)
|
|
if len(ws) != 1 || len(bs) != 0 || len(ws[0].Delivery.Merges) != 2 {
|
|
t.Fatalf("the restarted controller did not cut the batch at its window: %+v %+v", ws, bs)
|
|
}
|
|
}
|
|
|
|
// The window's settings reach the controller in its settings file, read at every look; one that says no
|
|
// duration, or no file, is the default.
|
|
func TestTheWindowIsTheControllersSetting(t *testing.T) {
|
|
file := t.TempDir() + "/merge-window.json"
|
|
t.Setenv(mergeWindowFileVar, file)
|
|
for _, c := range []struct {
|
|
content string
|
|
window, atMost time.Duration
|
|
}{
|
|
{`{"merge-window": "60s", "merge-window-at-most": "5m"}`, time.Minute, 5 * time.Minute},
|
|
{`{"merge-window": 30, "merge-window-at-most": ""}`, 30 * time.Second, mergeWindowAtMostDefault},
|
|
{`{"merge-window": "soon"}`, mergeWindowDefault, mergeWindowAtMostDefault},
|
|
} {
|
|
if err := os.WriteFile(file, []byte(c.content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w, m := mergeWindowOf(); w != c.window || m != c.atMost {
|
|
t.Errorf("%s reads as %s and %s, want %s and %s", c.content, w, m, c.window, c.atMost)
|
|
}
|
|
}
|
|
t.Setenv(mergeWindowFileVar, file+".gone")
|
|
if w, m := mergeWindowOf(); w != mergeWindowDefault || m != mergeWindowAtMostDefault {
|
|
t.Errorf("no file reads as %s and %s", w, m)
|
|
}
|
|
}
|
|
|
|
// S16 names every merge a waiting walk answers, never one commit a supersession may have ended (issue 362).
|
|
func TestAWaitingWalkNamesTheMergesItAnswers(t *testing.T) {
|
|
f := calm(t0)
|
|
f.waits = []waitFacts{{id: "plan-2", repository: "novox/mesh-catalog", commit: "48bda475dunst", awaits: "mesh-delivery",
|
|
since: t0.Add(-31 * time.Minute), merges: []inventory.PlanMerge{
|
|
{Repository: "novox/mesh-catalog", Commit: "553b7191claude", Carried: "48bda475dunst"},
|
|
{Repository: "novox/mesh-catalog", Commit: "48bda475dunst"}}}}
|
|
got := watchWaits(f)
|
|
if len(got) != 1 || !strings.Contains(got[0].Summary, "novox/mesh-catalog@553b7191") ||
|
|
!strings.Contains(got[0].Summary, "novox/mesh-catalog@48bda475") {
|
|
t.Fatalf("the waiting walk does not name both merges: %+v", got)
|
|
}
|
|
}
|
|
|
|
// A merge heard after a later merge of its branch was cut is named by that walk however its modules read since
|
|
// the cut, in a repository of one module (review of this change); a walk that never built what it moves names
|
|
// nothing, and the merge joins the next batch.
|
|
func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
ctx := t.Context()
|
|
c2 := repoMerge("one", "c2", t0.Add(-50*time.Second))
|
|
hear(t, open, c2, t0)
|
|
cutAt(t, open, t0.Add(2*time.Minute))
|
|
ws, _ := walks(t, open)
|
|
w := ws[0]
|
|
w.State = inventory.PlanDone
|
|
if err := open.inventory.SavePlan(ctx, &w); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hear(t, open, repoMerge("one", "c1", t0.Add(-60*time.Second)), t0.Add(15*time.Minute))
|
|
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
|
if len(got.Delivery.Merges) != 2 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one",
|
|
Commit: "c1", Carried: "c2"}) {
|
|
t.Fatalf("the late merge was not named by the walk that carried it: %+v", got.Delivery.Merges)
|
|
}
|
|
|
|
// A walk of two that built only two: a late merge of one is not its to name.
|
|
hear(t, open, repoMerge("two", "d2", t0.Add(16*time.Minute)), t0.Add(16*time.Minute))
|
|
cutAt(t, open, t0.Add(18*time.Minute))
|
|
ws, _ = walks(t, open)
|
|
w = ws[0]
|
|
delete(w.Modules, "two")
|
|
w.State = inventory.PlanDone
|
|
if err := open.inventory.SavePlan(ctx, &w); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hear(t, open, repoMerge("two", "d1", t0.Add(15*time.Minute)), t0.Add(20*time.Minute))
|
|
if got, _ := open.inventory.PlanByID(ctx, w.ID); len(got.Delivery.Merges) != 1 {
|
|
t.Fatalf("a walk that never built what the late merge moves named it: %+v", got.Delivery.Merges)
|
|
}
|
|
if _, bs := walks(t, open); len(bs) != 1 || bs[0].Delivery.Merges[0].Commit != "d1" {
|
|
t.Fatalf("the late merge did not join the next batch: %+v", bs)
|
|
}
|
|
}
|
|
|
|
// One walk at a time holds against the delivery's word too: a waiting walk is not let go while another started.
|
|
func TestAWaitingWalkIsNotLetGoBesideAStartedOne(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
ctx := t.Context()
|
|
hear(t, open, repoMerge("one", "c1", t0), t0)
|
|
cutAt(t, open, t0.Add(2*time.Minute))
|
|
started, _ := walks(t, open)
|
|
waiting := inventory.Plan{ID: "plan-waiting", Repository: "novox/two", Branch: "main", Commit: "d1", Created: t0,
|
|
State: inventory.PlanBuilding, Tiers: [][]string{{"two"}}, Modules: map[string]*inventory.PlanModule{"two": {}},
|
|
Delivery: &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}}
|
|
if err := open.inventory.SavePlan(ctx, &waiting); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err == nil ||
|
|
!strings.Contains(err.Error(), started[0].ID) {
|
|
t.Fatalf("a waiting walk was let go beside %s: %v", started[0].ID, err)
|
|
}
|
|
}
|
|
|
|
// A file a merge of the batch removed and a later one brought back is not removed; one removed last is.
|
|
func TestARemovedFileIsTheLastMergesWord(t *testing.T) {
|
|
ev := func(commit string, paths, removed []string) inventory.BatchedMerge {
|
|
m := link.SourceMoved{Owner: "novox", Repo: "one", Base: "main", Commit: commit, Paths: paths, Removed: removed}
|
|
b, _ := json.Marshal(m)
|
|
return inventory.BatchedMerge{Repository: "novox/one", Branch: "main", Commit: commit, Event: b,
|
|
Merged: t0.Add(time.Duration(len(commit)) * time.Second)}
|
|
}
|
|
got := combinedMerges([]inventory.BatchedMerge{
|
|
ev("a", []string{"x/module.json", "y/module.json"}, []string{"x/module.json", "y/module.json"}),
|
|
ev("bb", []string{"x/module.json"}, nil),
|
|
ev("ccc", []string{"z.go"}, nil)})
|
|
if len(got) != 1 || got[0].Commit != "ccc" || !slices.Equal(got[0].Removed, []string{"y/module.json"}) ||
|
|
len(got[0].Paths) != 3 {
|
|
t.Fatalf("combined as %+v", got)
|
|
}
|
|
}
|
|
|
|
// The catch-up hands over what the bus lost after its branch's later merges were cut, and the record keeps it:
|
|
// an earlier merge is named by the walk that carried it; a merge made before a cut and heard after it, which
|
|
// no later merge carries, joins the next batch — neither reads as history and is dropped (review of this change).
|
|
func TestTheCatchUpKeepsWhatACutMadeHistory(t *testing.T) {
|
|
open := windowed(t)
|
|
asksWithPaths(t)
|
|
ctx := t.Context()
|
|
base := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
|
|
hear(t, open, repoMerge("one", "c2", base.Add(10*time.Second)), base.Add(11*time.Second))
|
|
cutAt(t, open, base.Add(2*time.Minute))
|
|
ws, _ := walks(t, open)
|
|
w := ws[0]
|
|
w.State = inventory.PlanDone
|
|
if err := open.inventory.SavePlan(ctx, &w); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lost := []link.AnnouncedMerge{
|
|
{SourceMoved: repoMerge("one", "c1", base), At: base.Add(time.Second)},
|
|
{SourceMoved: repoMerge("one", "c3lost", base.Add(20*time.Second)), At: base.Add(21 * time.Second)},
|
|
}
|
|
catalogued := func(ctx context.Context) ([]inventory.Entry, map[string][]inventory.ReadRepository, error) {
|
|
entries, err := open.inventory.Catalogued(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
read, err := readForPlanning(ctx, open.inventory)
|
|
return entries, read, err
|
|
}
|
|
if err := catchUpOnMerges(ctx, time.Now(), unheardMerges{announcedList(lost), open.inventory}, catalogued,
|
|
(following{open}).SourceMoved, t.Logf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
|
if len(got.Delivery.Merges) != 2 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one",
|
|
Commit: "c1", Carried: "c2"}) {
|
|
t.Fatalf("the earlier merge the catch-up handed over is not named by the walk that carried it: %+v",
|
|
got.Delivery.Merges)
|
|
}
|
|
if _, kept, err := open.inventory.MergeOf(ctx, "novox/one", "c3lost"); err != nil || !kept {
|
|
t.Fatalf("the merge made before the cut and heard after it was dropped: %v %v", kept, err)
|
|
}
|
|
}
|
|
|
|
// A merge on the controller's own path never shares a batch with one that waits for mesh-delivery's word
|
|
// (decided during the build, 2026-10-10): the two are batches of their own, cut one after the other; a walk on
|
|
// the own path folds no waiting catalogue walk but batches its merges again behind it; the catalogue's walk
|
|
// still waits for the word, so no catalogue delivery skips its turn.
|
|
func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) {
|
|
open := windowed(t)
|
|
asked := asksWithPaths(t)
|
|
ctx := t.Context()
|
|
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-delivery", Version: "1",
|
|
Claims: []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}},
|
|
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/mesh-delivery", Ref: "main",
|
|
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hear(t, open, catalogueMerge("aaaa0001", "app", t0), t0)
|
|
hear(t, open, repoMerge("mesh-controller", "k1", t0.Add(10*time.Second)), t0.Add(10*time.Second))
|
|
hear(t, open, catalogueMerge("bbbb0002", "notes", t0.Add(20*time.Second)), t0.Add(20*time.Second))
|
|
_, bs := walks(t, open)
|
|
var ownBatch, catalogueBatch inventory.Plan
|
|
for _, b := range bs {
|
|
if b.OwnPath() {
|
|
ownBatch = b
|
|
} else {
|
|
catalogueBatch = b
|
|
}
|
|
}
|
|
if len(bs) != 2 || ownBatch.ID == "" || catalogueBatch.ID == "" || len(catalogueBatch.Delivery.Merges) != 2 ||
|
|
len(ownBatch.Delivery.Merges) != 1 {
|
|
t.Fatalf("a controller merge and two catalogue merges in one window: %+v", bs)
|
|
}
|
|
if !strings.Contains(batchWords(ownBatch, t0.Add(30*time.Second)), "own path") {
|
|
t.Fatalf("the own-path batch does not say so: %q", batchWords(ownBatch, t0.Add(30*time.Second)))
|
|
}
|
|
// The catalogue batch, the older, is cut first and waits for its word; the controller's batch is cut next:
|
|
// the waiting walk is batched again behind it, not folded into it.
|
|
cutAt(t, open, t0.Add(2*time.Minute))
|
|
ws, _ := walks(t, open)
|
|
if len(ws) != 1 || !ws[0].Waiting() {
|
|
t.Fatalf("the catalogue batch was not cut into a waiting walk: %+v", ws)
|
|
}
|
|
catalogueWalk := ws[0]
|
|
cutAt(t, open, t0.Add(2*time.Minute+5*time.Second))
|
|
ws, bs = walks(t, open)
|
|
var ownWalk inventory.Plan
|
|
for _, w := range ws {
|
|
if w.Open() {
|
|
ownWalk = w
|
|
}
|
|
}
|
|
if ownWalk.ID == "" || ownWalk.Waiting() || ownWalk.CommitOf("novox/mesh-controller") != "k1" {
|
|
t.Fatalf("the controller's batch was not cut into a started walk: %+v", ws)
|
|
}
|
|
for _, m := range []string{"app", "notes"} {
|
|
if _, in := ownWalk.Modules[m]; in {
|
|
t.Fatalf("the controller's walk builds %s, a catalogue module: it skipped its turn", m)
|
|
}
|
|
}
|
|
for _, a := range *asked {
|
|
if a[1] == "modules/app" || a[1] == "modules/notes" {
|
|
t.Fatalf("a catalogue module was asked without the word: %v", *asked)
|
|
}
|
|
}
|
|
folded, _ := open.inventory.PlanByID(ctx, catalogueWalk.ID)
|
|
if folded.State != inventory.PlanSuperseded || len(bs) != 1 || folded.Delivery.TakenOverBy != bs[0].ID ||
|
|
bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() ||
|
|
len(bs[0].Delivery.Merges) != 2 {
|
|
t.Fatalf("the waiting catalogue walk is %s (taken over by %q); batches %+v", folded.State,
|
|
folded.Delivery.TakenOverBy, bs)
|
|
}
|
|
// The controller's walk done, the catalogue batch is cut and waits for its word with both merges.
|
|
ownWalk.State = inventory.PlanDone
|
|
if err := open.inventory.SavePlan(ctx, &ownWalk); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cutAt(t, open, t0.Add(3*time.Minute))
|
|
ws, bs = walks(t, open)
|
|
if len(bs) != 0 || ws[0].ID != folded.Delivery.TakenOverBy || !ws[0].Waiting() || len(ws[0].Delivery.Merges) != 2 {
|
|
t.Fatalf("the catalogue batch was not cut into a waiting walk once the controller's ended: %+v %+v", ws, bs)
|
|
}
|
|
for _, m := range []string{"app", "notes"} {
|
|
if _, in := ws[0].Modules[m]; !in {
|
|
t.Fatalf("the catalogue walk does not build %s: %v", m, ws[0].Modules)
|
|
}
|
|
}
|
|
}
|