248 lines
8.4 KiB
Go
248 lines
8.4 KiB
Go
package link
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
)
|
|
|
|
// The consume side against a real server, because what is being checked is what the server does.
|
|
//
|
|
// Reasoning cannot answer any of these: whether a nak-with-delay really comes back, whether the
|
|
// delay is honoured, whether terminating a delivery really stops it, or whether an answer published
|
|
// to an address carried in the payload reaches a caller waiting on its own inbox. Each is a claim
|
|
// about a server, so each is asked of one:
|
|
//
|
|
// go test ./internal/link/ -run TestNats (each test on a bus of its own: internal/testbus)
|
|
|
|
// serving1 is a controller reading from a real bus, and a way to stop it.
|
|
func servingOn(t *testing.T, js *broker.JetStream, l Listener) (*Server, func()) {
|
|
t.Helper()
|
|
s := &Server{inbound: Nats(js), bus: OverNATS{Conn: js.Conn(), JS: js.Context()},
|
|
listener: l, log: quiet()}
|
|
ctx, stop := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { defer close(done); _ = s.Serve(ctx) }()
|
|
return s, func() {
|
|
stop()
|
|
<-done
|
|
}
|
|
}
|
|
|
|
// counted records reports and can be told to refuse them, from another goroutine.
|
|
type counted struct {
|
|
mu sync.Mutex
|
|
err error
|
|
heard []Report
|
|
}
|
|
|
|
func (c *counted) Heard(_ context.Context, r Report) (bool, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.err != nil {
|
|
return false, c.err
|
|
}
|
|
c.heard = append(c.heard, r)
|
|
// News, so what the mesh states about a report is exercised wherever a report is.
|
|
return true, nil
|
|
}
|
|
|
|
func (c *counted) refusing(err error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.err = err
|
|
}
|
|
|
|
func (c *counted) count() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return len(c.heard)
|
|
}
|
|
|
|
func eventually(t *testing.T, what string, is func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(8 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if is() {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("%s did not happen within the wait", what)
|
|
}
|
|
|
|
// A report published by a node reaches the controller, is recorded, and is acknowledged — so the
|
|
// stream does not hold it. A work queue is the check: what is acknowledged leaves it.
|
|
func quiet() *log.Logger { return log.New(io.Discard, "", 0) }
|
|
|
|
// **The store window, in the server.** A report the store cannot take is naked with a delay and
|
|
// comes back; once the store is there it is recorded and leaves the stream. The controller holds
|
|
// nothing in the meantime — which is what the sequence check below is for: the message is still on
|
|
// the server while it waits.
|
|
|
|
// A report about a declaration the mesh has moved past is settled without being acted on, and
|
|
// leaves the stream rather than coming back for ever.
|
|
|
|
// sentAndHeardSafely is sentAndHeard, read from two goroutines.
|
|
type sentAndHeardSafely struct {
|
|
mu sync.Mutex
|
|
sent string
|
|
heard []Report
|
|
}
|
|
|
|
func (s *sentAndHeardSafely) Heard(_ context.Context, r Report) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.heard = append(s.heard, r)
|
|
return true, nil
|
|
}
|
|
|
|
func (s *sentAndHeardSafely) Outstanding(context.Context, string) (string, error) {
|
|
return s.sent, nil
|
|
}
|
|
|
|
func (s *sentAndHeardSafely) count() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.heard)
|
|
}
|
|
|
|
// **An enrolment answered through a reply address the stream would have eaten.**
|
|
//
|
|
// The caller waits on its own inbox and states that address in the request's payload. The check is
|
|
// that the answer arrives there — which is the whole reason the address is a field rather than the
|
|
// transport's reply, and this is the test design 25 §2 asks for so the reason cannot quietly become
|
|
// folklore.
|
|
|
|
// enrolsAs answers every request the same way.
|
|
type enrolsAs struct{ reply EnrolReply }
|
|
|
|
func (e enrolsAs) Enrol(context.Context, EnrolRequest) (EnrolReply, error) { return e.reply, nil }
|
|
|
|
// A heartbeat is core NATS: it reaches the controller and nothing is persisted, so the stream the
|
|
// reports live in stays empty.
|
|
|
|
// The two events the controller follows arrive over one durable consumer with two filters, and it
|
|
// can acknowledge them.
|
|
//
|
|
// **Both halves are the point.** A consumer with several filter subjects is a 2.10 feature and this
|
|
// is the first thing in the mesh to use one; and a delivery from the events stream is acknowledged
|
|
// on a different ack subject from a delivery from the control stream, which the controller's own
|
|
// permission list has to cover or every announcement is redelivered for ever.
|
|
|
|
type toldAbout struct {
|
|
mu sync.Mutex
|
|
saw []Upgraded
|
|
fail error
|
|
}
|
|
|
|
func (u *toldAbout) Upgraded(_ context.Context, m Upgraded) error {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
if u.fail != nil {
|
|
return u.fail
|
|
}
|
|
u.saw = append(u.saw, m)
|
|
return nil
|
|
}
|
|
|
|
func (u *toldAbout) count() int {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
return len(u.saw)
|
|
}
|
|
|
|
// A store that never comes back: the report is let go once the bound passes, and it leaves the
|
|
// stream rather than being held for ever. The bound is the controller's, not the server's — nothing
|
|
// here sets max-deliver, and that is deliberate (streams.go).
|
|
|
|
// A merge announcement is not what these tests are about; taken and forgotten.
|
|
func (t *toldAbout) SourceMoved(context.Context, SourceMoved) error { return nil }
|
|
|
|
// Every subject the controller follows decodes to a kind, and decoding never reaches past the
|
|
// list: the day the list was three long and the decoder named a fourth, every message panicked
|
|
// the control plane (2026-09-28).
|
|
func TestEverySubjectTheControllerFollowsDecodesToAKind(t *testing.T) {
|
|
for _, subject := range broker.ControllerFollows {
|
|
if _, ok := kindOfSubject(subject); !ok {
|
|
t.Errorf("%s is followed and decodes to nothing", subject)
|
|
}
|
|
}
|
|
if _, ok := kindOfSubject("mesh.mod.nobody.event.nothing"); ok {
|
|
t.Error("a subject nobody follows decoded to a kind")
|
|
}
|
|
}
|
|
|
|
// **A handler slower than the acknowledgement window is not handed its message again.**
|
|
//
|
|
// The bus waits a fixed time to be told a message was taken and then redelivers, which is right for
|
|
// a consumer that died and wrong for one that is busy. Acting on a merge builds modules — minutes
|
|
// against a thirty-second window — and the same merge was handed over five times while the first
|
|
// build was still running (2026-09-28). Here the window is a second and the work takes three.
|
|
|
|
// slowly is a listener that runs whatever it was given.
|
|
type slowly struct{ work func() }
|
|
|
|
func (s slowly) Heard(context.Context, Report) (bool, error) { s.work(); return true, nil }
|
|
|
|
// **The mesh says what it applied** (novox/hq ADR 0134), under the seat the control plane holds — and
|
|
// says nothing when a report is the same state said again, which is what a machine reconciling every
|
|
// minute sends.
|
|
|
|
// And a report that is not news is not a fact. A machine reconciles every minute; a fact per report
|
|
// would be a fact per minute per machine, which is a stream nobody reads.
|
|
|
|
// sameAgain records a report and says it was the same state said again.
|
|
type sameAgain struct{}
|
|
|
|
func (sameAgain) Heard(context.Context, Report) (bool, error) { return false, nil }
|
|
|
|
// manyTimesBusy is an upgrader whose plans are busy for its first merges, as another controller's hold is.
|
|
type manyTimesBusy struct {
|
|
toldAbout
|
|
busy int
|
|
calls int
|
|
}
|
|
|
|
func (u *manyTimesBusy) SourceMoved(context.Context, SourceMoved) error {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
u.calls++
|
|
if u.calls <= u.busy {
|
|
return errors.Join(ErrTryAgain, errors.New("another controller is working the plans"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *manyTimesBusy) called() int {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
return u.calls
|
|
}
|
|
|
|
// **A merge held more than five times is still handed over and recorded** (issue 973, novox/hq ADR 0407 decision
|
|
// 3): the merges have a durable consumer of their own with no max-deliver, so they never go to DEAD_LETTERS to wait
|
|
// for a person; the delay grows, and is capped.
|
|
|
|
// The delay a held merge waits grows from the first to the most and stays there.
|
|
func TestAHeldMergesDelayGrowsAndIsCapped(t *testing.T) {
|
|
var got []time.Duration
|
|
for _, held := range []time.Duration{0, 4 * time.Second, 10 * time.Second, 25 * time.Second, 70 * time.Second,
|
|
200 * time.Second, 20 * time.Minute} {
|
|
got = append(got, MergeRedeliverAfter(held))
|
|
}
|
|
want := []time.Duration{5 * time.Second, 5 * time.Second, 10 * time.Second, 25 * time.Second, 70 * time.Second,
|
|
200 * time.Second, 5 * time.Minute}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("held %d: waits %v; want %v", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|