Read a merge's manifests and lock files at most eight at once, so a catalogue merge stays inside the bridge's bound (issues 1448, 1458, design 47 R4c-2)
mesh/repo-check head c9f742d5f20e merged into trunk 2520cc4a88f7 — pass: its merge-check.sh passed
mesh/merge-gate head c9f742d5f20e merged into trunk 2520cc4a88f7 — pass: group feat/r4c2-new-keys composed: every machine composes with the change as it di…
mesh/delivery delivered
mesh/delivery-group group feat/r4c2-new-keys delivered: every member is delivered

This commit is contained in:
2026-10-20 19:06:03 +00:00
parent f69a561a16
commit c9f742d5f2
3 changed files with 208 additions and 17 deletions
@@ -8,6 +8,7 @@ import (
"log" "log"
"slices" "slices"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -139,8 +140,11 @@ func TestMergeCallsTheToolOnceAndWritesTheMergedMessage(t *testing.T) { // 1236/
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.merged", data: mergedEvent()}, &fakePub{} m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.merged", data: mergedEvent()}, &fakePub{}
var keys []string var keys []string
var args []any var args []any
var mu sync.Mutex // the files are asked for concurrently (issue 1458)
call := func(_ context.Context, key string, body any) (json.RawMessage, error) { call := func(_ context.Context, key string, body any) (json.RawMessage, error) {
mu.Lock()
keys, args = append(keys, key), append(args, body) keys, args = append(keys, key), append(args, body)
mu.Unlock()
if key == "gitea.gitea_get_file" { // the manifests, 1448; the trees are what this test is about if key == "gitea.gitea_get_file" { // the manifests, 1448; the trees are what this test is about
return json.RawMessage(`{"file":{"path":"p","sha":"0123","size":2,"content":"{}"}}`), nil return json.RawMessage(`{"file":{"path":"p","sha":"0123","size":2,"content":"{}"}}`), nil
} }
@@ -7,10 +7,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"path"
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -135,38 +137,84 @@ func Convert(subject string, data []byte, trees Trees, files Files) (Message, er
return Message{Event: event, Data: body}, nil return Message{Event: event, Data: body}, nil
} }
// MaxReadsInFlight is how many files of the forge inputsOf asks for at once. A merge into a repository of many
// modules (131 in the catalogue) reads three files for each, one after another inside the bound of a handled event
// (30 s) that leaves about 76 ms a call; eight at once keeps the forge's load small and the read well inside the bound
// (issue 1458).
const MaxReadsInFlight = 8
// read is one file asked for: the path in the directory dir, and whether it is the module's manifest.
type read struct {
dir, path string
manifest bool
raw []byte
err error
}
// inputsOf reads, at ref, the module.json of each directory (the root spelled "") and the lock files beside it // inputsOf reads, at ref, the module.json of each directory (the root spelled "") and the lock files beside it
// (LockFiles): the manifests as the forge answered them and the lock files' hex sha256 by name. A manifest the forge // (LockFiles): the manifests as the forge answered them and the lock files' hex sha256 by name. A manifest the forge
// does not have is an error, since the directory was said to hold one; a lock file it does not have is left out; any other // does not have is an error, since the directory was said to hold one; a lock file it does not have is left out; any other
// failure is an error (issue 1448). // failure is an error (issue 1448).
//
// The files are asked for with at most MaxReadsInFlight calls at once (issue 1458). The result does not depend on the
// order the calls finish in, and the error is that of the first failing file in the order of dirs and LockFiles. After
// a failure no further file is asked for, and inputsOf returns only when every call it started has returned.
func inputsOf(owner, repo, ref string, dirs []string, files Files) (manifests map[string]string, locks map[string]map[string]string, err error) { func inputsOf(owner, repo, ref string, dirs []string, files Files) (manifests map[string]string, locks map[string]map[string]string, err error) {
manifests = map[string]string{}
locks = map[string]map[string]string{}
at := func(dir, name string) string { at := func(dir, name string) string {
if dir == "" { if dir == "" {
return name return name
} }
return dir + "/" + name return dir + "/" + name
} }
var reads []*read
for _, dir := range dirs { for _, dir := range dirs {
raw, err := files(owner, repo, at(dir, "module.json"), ref) reads = append(reads, &read{dir: dir, path: at(dir, "module.json"), manifest: true})
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", at(dir, "module.json"), err)
}
manifests[dir] = string(raw)
for _, name := range LockFiles { for _, name := range LockFiles {
raw, err := files(owner, repo, at(dir, name), ref) reads = append(reads, &read{dir: dir, path: at(dir, name)})
if errors.Is(err, ErrNotFound) { }
continue }
var (
wg sync.WaitGroup
mu sync.Mutex
failed bool
slots = make(chan struct{}, MaxReadsInFlight)
)
for _, r := range reads {
slots <- struct{}{}
mu.Lock()
stop := failed
mu.Unlock()
if stop {
<-slots
break
}
wg.Add(1)
go func(r *read) {
defer func() { <-slots; wg.Done() }()
r.raw, r.err = files(owner, repo, r.path, ref)
if r.err != nil && (r.manifest || !errors.Is(r.err, ErrNotFound)) {
mu.Lock()
failed = true
mu.Unlock()
} }
if err != nil { }(r)
return nil, nil, fmt.Errorf("%s: %w", at(dir, name), err) }
wg.Wait()
manifests = map[string]string{}
locks = map[string]map[string]string{}
for _, r := range reads {
switch {
case r.err != nil && (r.manifest || !errors.Is(r.err, ErrNotFound)):
return nil, nil, fmt.Errorf("%s: %w", r.path, r.err)
case r.err != nil: // a lock file the forge does not have
case r.manifest:
manifests[r.dir] = string(r.raw)
default:
sum := sha256.Sum256(r.raw)
if locks[r.dir] == nil {
locks[r.dir] = map[string]string{}
} }
sum := sha256.Sum256(raw) locks[r.dir][path.Base(r.path)] = hex.EncodeToString(sum[:])
if locks[dir] == nil {
locks[dir] = map[string]string{}
}
locks[dir][name] = hex.EncodeToString(sum[:])
} }
} }
if len(manifests) == 0 { if len(manifests) == 0 {
@@ -4,10 +4,14 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"os" "os"
"reflect" "reflect"
"sort"
"strings" "strings"
"sync"
"testing" "testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -32,11 +36,14 @@ type fileCall struct{ owner, repo, path, ref string }
type forgeFiles struct { type forgeFiles struct {
have map[string][]byte have map[string][]byte
errs map[string]error errs map[string]error
mu sync.Mutex
calls []fileCall calls []fileCall
} }
func (f *forgeFiles) get(owner, repo, path, ref string) ([]byte, error) { func (f *forgeFiles) get(owner, repo, path, ref string) ([]byte, error) {
f.mu.Lock()
f.calls = append(f.calls, fileCall{owner, repo, path, ref}) f.calls = append(f.calls, fileCall{owner, repo, path, ref})
f.mu.Unlock()
if err, ok := f.errs[path]; ok { if err, ok := f.errs[path]; ok {
return nil, err return nil, err
} }
@@ -136,7 +143,139 @@ func TestTheFilesAreAskedAtTheMergeCommitByOwnerRepoPathAndRef(t *testing.T) { /
} }
want := []fileCall{{"novox", "mesh-delivery", "modules/a/module.json", merge}, {"novox", "mesh-delivery", "modules/a/go.sum", merge}, want := []fileCall{{"novox", "mesh-delivery", "modules/a/module.json", merge}, {"novox", "mesh-delivery", "modules/a/go.sum", merge},
{"novox", "mesh-delivery", "modules/a/package-lock.json", merge}} {"novox", "mesh-delivery", "modules/a/package-lock.json", merge}}
sort.Slice(forge.calls, func(i, j int) bool { return forge.calls[i].path < forge.calls[j].path }) // they are asked for concurrently
sort.Slice(want, func(i, j int) bool { return want[i].path < want[j].path })
if !reflect.DeepEqual(forge.calls, want) { if !reflect.DeepEqual(forge.calls, want) {
t.Errorf("calls = %v, want %v", forge.calls, want) t.Errorf("calls = %v, want %v", forge.calls, want)
} }
} }
// slowForge answers like a forge that takes d a call: a module.json of the directory's name everywhere, a go.sum in
// the directories whose number is even and in the root, and counts the calls in flight.
type slowForge struct {
d time.Duration
mu sync.Mutex
inFlight, most, started, n int
failOn string
finished int
}
func (f *slowForge) get(_, _, path, _ string) ([]byte, error) {
f.mu.Lock()
f.inFlight++
f.started++
if f.inFlight > f.most {
f.most = f.inFlight
}
f.mu.Unlock()
time.Sleep(f.d)
defer func() { f.mu.Lock(); f.inFlight--; f.finished++; f.mu.Unlock() }()
switch {
case path == f.failOn:
return nil, errors.New("Gitea API " + path + ": 502 bad gateway")
case strings.HasSuffix(path, "module.json"):
return []byte(`{"module":"` + path + `"}`), nil
case strings.HasSuffix(path, "go.sum") && (path == "go.sum" || path[len(path)-8]%2 == 0):
return []byte("sum of " + path), nil
}
return nil, ErrNotFound
}
func manyDirs(n int) []string {
dirs := []string{""}
for i := 1; i < n; i++ {
dirs = append(dirs, fmt.Sprintf("modules/m%02d", i))
}
return dirs
}
func TestFortyDirectoriesAreReadEightAtATimeWithTheResultOfOneAtATime(t *testing.T) { // 1458/AC1
dirs := manyDirs(40)
slow := &slowForge{d: 10 * time.Millisecond}
manifests, locks, err := inputsOf("novox", "mesh-catalog", merge, dirs, slow.get)
if err != nil {
t.Fatal(err)
}
if slow.most > MaxReadsInFlight || slow.most < 2 {
t.Errorf("at most %d calls were in flight, want more than one and at most %d", slow.most, MaxReadsInFlight)
}
if slow.started != 40*3 {
t.Errorf("%d calls, want 120", slow.started)
}
// The same read one file at a time.
wantM, wantL := map[string]string{}, map[string]map[string]string{}
one := &slowForge{}
for _, dir := range dirs {
m, l, err := inputsOfOne(dir, one.get)
if err != nil {
t.Fatal(err)
}
wantM[dir] = m
if l != nil {
wantL[dir] = l
}
}
if !reflect.DeepEqual(manifests, wantM) || !reflect.DeepEqual(locks, wantL) {
t.Errorf("the concurrent read differs from a one-at-a-time read: %d manifests %d locks, want %d and %d", len(manifests), len(locks), len(wantM), len(wantL))
}
if len(locks) == 0 || len(locks) == 40 {
t.Errorf("%d directories with locks, the fixture should have some and not all", len(locks))
}
}
// inputsOfOne reads one directory with a single call at a time, as the bridge did before issue 1458.
func inputsOfOne(dir string, files Files) (string, map[string]string, error) {
at := func(name string) string {
if dir == "" {
return name
}
return dir + "/" + name
}
raw, err := files("o", "r", at("module.json"), "x")
if err != nil {
return "", nil, err
}
var locks map[string]string
for _, name := range LockFiles {
b, err := files("o", "r", at(name), "x")
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return "", nil, err
}
if locks == nil {
locks = map[string]string{}
}
s := sha256.Sum256(b)
locks[name] = hex.EncodeToString(s[:])
}
return string(raw), locks, nil
}
func TestOneFailingFileIsRetryAndEveryCallHasReturnedBeforeTheReadDoes(t *testing.T) { // 1458/AC2
dirs := manyDirs(40)
slow := &slowForge{d: 10 * time.Millisecond, failOn: "modules/m05/go.sum"}
_, _, err := inputsOf("novox", "mesh-catalog", merge, dirs, slow.get)
if err == nil || !strings.Contains(err.Error(), "modules/m05/go.sum") || !strings.Contains(err.Error(), "502") {
t.Fatalf("err = %v, want the failing file named", err)
}
slow.mu.Lock()
defer slow.mu.Unlock()
if slow.inFlight != 0 || slow.finished != slow.started {
t.Errorf("%d calls in flight, %d of %d finished, when the read returned", slow.inFlight, slow.finished, slow.started)
}
if slow.started >= 40*3 {
t.Errorf("%d calls were made, want the read to stop asking after the failure", slow.started)
}
// Through Convert it is ErrRetry.
var calls []call
dirsMap := map[string]string{}
for _, d := range dirs[1:] {
dirsMap[d] = "t"
}
slow2 := &slowForge{failOn: "modules/m05/module.json"}
if _, err := Convert(mergedSubject, mergedBody(nil), treesOf(&calls, "r0", dirsMap, nil), slow2.get); !errors.Is(err, ErrRetry) {
t.Errorf("err = %v, want ErrRetry", err)
}
}