mesh/repo-check head ca667af562b0 merged into trunk 1325c8b452c8 beside mesh-controller#400@a0c971ce — pass: its merge-check.sh passed
mesh/merge-gate head ca667af562b0 merged into trunk 1325c8b452c8 — pass: group fix/1279-consumers-from-now composed: every machine composes with the change…
mesh/delivery delivered
mesh/delivery-group group fix/1279-consumers-from-now delivered: every member is delivered
293 lines
11 KiB
Go
293 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// openAnswer is gitea_open_pulls's answer as mesh-catalog modules/gitea/client.ts:369-381 builds its entries inside {pulls}.
|
|
func openAnswer() json.RawMessage {
|
|
return json.RawMessage(`{"pulls":[
|
|
{"owner":"novox","repo":"mesh-delivery","number":12,"base":"main","head":"feat/x","head_sha":"` + head + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]},
|
|
{"owner":"novox","repo":"mesh-catalog","number":300,"base":"main","head":"feat/x","head_sha":"` + strings.Repeat("a", 40) + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]},
|
|
{"owner":"novox","repo":"mesh-catalog","number":301,"base":"feat/y","head":"feat/z","head_sha":"` + strings.Repeat("b", 40) + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]}]}`)
|
|
}
|
|
|
|
func TestSyncOpenWritesOneUpdatedMessagePerOpenPull(t *testing.T) { // 1237/AC1, AC4
|
|
var logs bytes.Buffer
|
|
pub := &fakePub{}
|
|
var keys []string
|
|
call := func(_ context.Context, key string, _ any) (json.RawMessage, error) {
|
|
keys = append(keys, key)
|
|
return openAnswer(), nil
|
|
}
|
|
if err := runSync(call, pub, newHeads(), &sync.Mutex{}, log.New(&logs, "", 0)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(keys) != 1 || keys[0] != "gitea.gitea_open_pulls" {
|
|
t.Errorf("calls %v", keys)
|
|
}
|
|
if len(pub.subjects) != 3 {
|
|
t.Fatalf("%d messages written (%v), want two pull messages and the open message: the pull request into feat/y gets none", len(pub.subjects), pub.subjects)
|
|
}
|
|
want := []string{"mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.pull", "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-catalog.pull"}
|
|
for i, w := range want {
|
|
f, err := delivery.DecodeForge(pub.data[i])
|
|
if pub.subjects[i] != w || err != nil || f.Action != "updated" || f.DirsKnown || f.Branch != "feat/x" {
|
|
t.Errorf("message %d on %s = %+v err %v", i, pub.subjects[i], f, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// memHeads is the bucket heads in memory.
|
|
type memHeads struct {
|
|
mu sync.Mutex
|
|
vals map[string]string
|
|
getErr error
|
|
}
|
|
|
|
func newHeads(kv ...string) *memHeads {
|
|
m := &memHeads{vals: map[string]string{}}
|
|
for i := 0; i+1 < len(kv); i += 2 {
|
|
m.vals[kv[i]] = kv[i+1]
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *memHeads) Get(_ context.Context, key string) (string, bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
v, ok := m.vals[key]
|
|
return v, ok, m.getErr
|
|
}
|
|
func (m *memHeads) Put(_ context.Context, key, head string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.vals[key] = head
|
|
return nil
|
|
}
|
|
func (m *memHeads) Delete(_ context.Context, key string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.vals, key)
|
|
return nil
|
|
}
|
|
func (m *memHeads) get(key string) string { m.mu.Lock(); defer m.mu.Unlock(); return m.vals[key] }
|
|
|
|
// runSync runs syncOpen with the recorder the program writes through.
|
|
func runSync(call caller, pub publisher, heads headStore, mu *sync.Mutex, logger *log.Logger) error {
|
|
return syncOpen(context.Background(), call, &recorder{pub: pub, heads: heads, logger: logger}, heads, mu, logger)
|
|
}
|
|
|
|
// 1279/AC6: the sync writes a pull message only for a head that differs from what FORGE holds, then one open message
|
|
// naming every open pull request into trunk it read, those whose head did not differ among them.
|
|
func TestSyncOpenWritesPullsForNewHeadsThenOneOpenMessageNamingAll(t *testing.T) { // 1279/AC6
|
|
pub := &fakePub{}
|
|
call := func(context.Context, string, any) (json.RawMessage, error) { return openAnswer(), nil }
|
|
heads := newHeads("novox.mesh-delivery.12", head)
|
|
var logs bytes.Buffer
|
|
if err := runSync(call, pub, heads, &sync.Mutex{}, log.New(&logs, "", 0)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if heads.get("novox.mesh-catalog.300") != strings.Repeat("a", 40) || heads.get("novox.mesh-delivery.12") != head {
|
|
t.Errorf("the bucket after the sync: %v, want the head written for mesh-catalog#300 kept beside the held one", heads.vals)
|
|
}
|
|
if len(pub.subjects) != 2 {
|
|
t.Fatalf("%d messages written (%v), want the one pull message that differs and the open message", len(pub.subjects), pub.subjects)
|
|
}
|
|
if pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-catalog.pull" {
|
|
t.Errorf("first message on %s, want mesh-catalog's pull message", pub.subjects[0])
|
|
}
|
|
if pub.subjects[1] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.open" {
|
|
t.Fatalf("last message on %s, want the open message after the pull messages", pub.subjects[1])
|
|
}
|
|
f, err := delivery.DecodeForge(pub.data[1])
|
|
want := []delivery.OpenPull{{Repository: "novox/mesh-catalog", Number: 300}, {Repository: "novox/mesh-delivery", Number: 12}}
|
|
if err != nil || len(f.Open) != 2 || f.Open[0] != want[0] || f.Open[1] != want[1] {
|
|
t.Errorf("open message = %+v err %v, want it to name %v, the pull request whose head did not differ among them", f, err, want)
|
|
}
|
|
}
|
|
|
|
// The bucket cannot be read: every open pull request is written, as before, and the open message follows; the
|
|
// indexer's writes are idempotent, so the fault costs messages and no state.
|
|
func TestSyncOpenWritesAllWhenTheHeadsCannotBeRead(t *testing.T) { // 1279/AC6
|
|
pub := &fakePub{}
|
|
call := func(context.Context, string, any) (json.RawMessage, error) { return openAnswer(), nil }
|
|
heads := newHeads("novox.mesh-delivery.12", head)
|
|
heads.getErr = errors.New("the bucket is gone")
|
|
var logs bytes.Buffer
|
|
if err := runSync(call, pub, heads, &sync.Mutex{}, log.New(&logs, "", 0)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(pub.subjects) != 3 || !strings.Contains(logs.String(), "the bucket is gone") {
|
|
t.Errorf("messages %v, log %q", pub.subjects, logs.String())
|
|
}
|
|
}
|
|
|
|
// What the bridge writes on FORGE it keeps in the bucket: a head by its pull message, nothing by a closed or merged one.
|
|
func TestWhatIsWrittenOnFORGEIsKeptInTheBucket(t *testing.T) { // 1279/AC6
|
|
pub := &fakePub{}
|
|
heads := newHeads()
|
|
r := &recorder{pub: pub, heads: heads, logger: log.New(&bytes.Buffer{}, "", 0)}
|
|
write := func(f delivery.Forge) {
|
|
t.Helper()
|
|
ev, _ := delivery.ForgeEvent(f.Repository, f.Kind)
|
|
raw, err := delivery.EncodeForge(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := r.Publish(context.Background(), forgeSubject(ev), raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
p := delivery.Forge{Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: delivery.PullUpdated}
|
|
write(p)
|
|
if heads.get("novox.mesh-delivery.12") != head {
|
|
t.Fatalf("bucket %v", heads.vals)
|
|
}
|
|
p.Action = delivery.PullClosed
|
|
write(p)
|
|
if _, kept := heads.vals["novox.mesh-delivery.12"]; kept {
|
|
t.Errorf("a closed pull request is still kept: %v", heads.vals)
|
|
}
|
|
p.Action = delivery.PullUpdated
|
|
write(p)
|
|
write(delivery.Forge{Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Merge: "m", Root: "r", Trees: map[string]string{"": "t"}})
|
|
if _, kept := heads.vals["novox.mesh-delivery.12"]; kept {
|
|
t.Errorf("a merged pull request is still kept: %v", heads.vals)
|
|
}
|
|
if len(pub.subjects) != 4 {
|
|
t.Errorf("%d messages written", len(pub.subjects))
|
|
}
|
|
}
|
|
|
|
// 1279/AC6: no forge event is heard between reading the forge and writing the open message.
|
|
func TestNoForgeEventIsHeardWhileTheOpenPullsAreSaid(t *testing.T) { // 1279/AC6
|
|
pub := &lockedPub{}
|
|
reading := make(chan struct{})
|
|
release := make(chan struct{})
|
|
call := func(context.Context, string, any) (json.RawMessage, error) {
|
|
close(reading)
|
|
<-release
|
|
return openAnswer(), nil
|
|
}
|
|
var mu sync.Mutex
|
|
synced := make(chan error, 1)
|
|
go func() {
|
|
synced <- runSync(call, pub, newHeads(), &mu, log.New(&bytes.Buffer{}, "", 0))
|
|
}()
|
|
<-reading
|
|
// An event arrives while the forge is being read.
|
|
heard := make(chan struct{})
|
|
go func() {
|
|
hear(&mu, func() { _, _ = pub.Publish(context.Background(), "event", nil); close(heard) })
|
|
}()
|
|
select {
|
|
case <-heard:
|
|
t.Fatal("an event was heard while the forge was read")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
close(release)
|
|
if err := <-synced; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-heard
|
|
got := pub.snapshot()
|
|
if len(got) != 4 || got[2] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.open" || got[3] != "event" {
|
|
t.Errorf("order of writes: %v, want the event after the open message", got)
|
|
}
|
|
}
|
|
|
|
type lockedPub struct {
|
|
mu sync.Mutex
|
|
subjects []string
|
|
}
|
|
|
|
func (p *lockedPub) Publish(_ context.Context, subject string, _ []byte, _ ...jetstream.PublishOpt) (*jetstream.PubAck, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.subjects = append(p.subjects, subject)
|
|
return &jetstream.PubAck{}, nil
|
|
}
|
|
func (p *lockedPub) snapshot() []string {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return append([]string(nil), p.subjects...)
|
|
}
|
|
|
|
func TestSyncOpenFailureNamesTheCallAndTheForgesWords(t *testing.T) { // 1237/AC3
|
|
pub := &fakePub{}
|
|
call := func(context.Context, string, any) (json.RawMessage, error) {
|
|
return nil, errors.New("the forge said 502")
|
|
}
|
|
err := runSync(call, pub, newHeads(), &sync.Mutex{}, log.New(&bytes.Buffer{}, "", 0))
|
|
if err == nil || !strings.Contains(err.Error(), "gitea.gitea_open_pulls") || !strings.Contains(err.Error(), "the forge said 502") {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
if len(pub.subjects) != 0 {
|
|
t.Errorf("messages written: %v", pub.subjects)
|
|
}
|
|
}
|
|
|
|
func TestReconnectReadsTheOpenPullsAgainAndFailureGoesOn(t *testing.T) { // 1237/AC2, AC3
|
|
var logs lockedBuffer
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
again := make(chan struct{}, 1)
|
|
done := make(chan struct{})
|
|
var mu sync.Mutex
|
|
n := 0
|
|
count := func() int { mu.Lock(); defer mu.Unlock(); return n }
|
|
read := func(context.Context) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
n++
|
|
if n == 1 {
|
|
return errors.New("the forge said 502")
|
|
}
|
|
return nil
|
|
}
|
|
go func() { onReconnect(ctx, again, read, log.New(&logs, "", 0)); close(done) }()
|
|
again <- struct{}{}
|
|
waitFor(t, func() bool { return count() >= 1 })
|
|
again <- struct{}{} // the first read failed; the bridge goes on and reads again at the next reconnect
|
|
waitFor(t, func() bool { return count() >= 2 })
|
|
cancel()
|
|
<-done
|
|
if !strings.Contains(logs.String(), "the forge said 502") {
|
|
t.Errorf("log %q does not carry the forge's words", logs.String())
|
|
}
|
|
}
|
|
|
|
type lockedBuffer struct {
|
|
mu sync.Mutex
|
|
b bytes.Buffer
|
|
}
|
|
|
|
func (l *lockedBuffer) Write(p []byte) (int, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.b.Write(p)
|
|
}
|
|
func (l *lockedBuffer) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.b.String() }
|
|
|
|
func waitFor(t *testing.T, ok func() bool) {
|
|
t.Helper()
|
|
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
|
|
if ok() {
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("it did not happen within 5 s")
|
|
}
|