mesh/merge-gate head 45870ab31cc5 merged into trunk 3479a35462b2 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 45870ab31cc5 merged into trunk 3479a35462b2 — pass: its merge-check.sh passed
mesh/delivery delivered
A storm of closures must not spend a watchdog tick, so the read stops at 2000 events and says the count is a floor. A failed read is its own condition rather than blinding S9, so the controller's own signals are still said. The controller's fall behind, told to its client and closed by the server, is one fault, counted once.
122 lines
4.9 KiB
Go
122 lines
4.9 KiB
Go
package link
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/testbus"
|
|
)
|
|
|
|
// What the bus module says of its users' connections, read back as advisories (novox/hq ADR 0357, issue
|
|
// 270): one per user and kind, each closure counted once however often it is said.
|
|
//
|
|
// go test ./internal/link/ -run TestNatsBusFaults (each test on a bus of its own: internal/testbus)
|
|
|
|
func aBusWithEvents(t *testing.T) *Server {
|
|
t.Helper()
|
|
js, err := broker.Dial(testbus.URL(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(js.Close)
|
|
if err := broker.AssertMeshStreams(js); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ConnectNats(js, nil, nil)
|
|
}
|
|
|
|
func sayClosure(t *testing.T, s *Server, event string, c BusClosure) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(c)
|
|
if _, err := s.js.Context().Publish(EventSubject(BusModule, event), body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestNatsBusFaultsAreOnePerUserAndKindEachClosureCountedOnce(t *testing.T) {
|
|
s := aBusWithEvents(t)
|
|
now := time.Now().UTC()
|
|
slow := BusClosure{User: "laptop.claude-code", Name: "laptop.claude-code", CID: 10, Reason: "Slow Consumer (Write Deadline)",
|
|
Opened: now.Add(-20 * time.Minute), Closed: now.Add(-2 * time.Minute), Pending: 334000}
|
|
sayClosure(t, s, BusSlowConsumerEvent, slow)
|
|
sayClosure(t, s, BusSlowConsumerEvent, slow) // said again: the module started again
|
|
again := slow
|
|
again.CID, again.Opened, again.Closed = 31, now.Add(-90*time.Second), now.Add(-time.Minute)
|
|
sayClosure(t, s, BusSlowConsumerEvent, again)
|
|
sayClosure(t, s, BusConnectionClosedEvent, BusClosure{User: "anchor.desk", CID: 7,
|
|
Reason: "Maximum Message Payload Exceeded", Opened: now.Add(-time.Hour / 2), Closed: now.Add(-30 * time.Second)})
|
|
// Older than the hour: S9 no longer says it.
|
|
sayClosure(t, s, BusSlowConsumerEvent, BusClosure{User: "anchor.media", CID: 3, Reason: "Slow Consumer (Pending Bytes)",
|
|
Opened: now.Add(-3 * time.Hour), Closed: now.Add(-2 * time.Hour)})
|
|
|
|
got, err := s.BusFaults(t.Context(), now.Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("read %d advisories, want one for each user within the hour: %+v", len(got), got)
|
|
}
|
|
for _, a := range got {
|
|
switch a.User {
|
|
case "laptop.claude-code":
|
|
if a.Kind != AdvisorySlowConsumer || a.ID != "laptop.claude-code" || a.Count != 2 ||
|
|
!a.Last.Equal(again.Closed) || !a.First.Equal(slow.Closed) || !strings.Contains(a.Said, "connection 31") {
|
|
t.Errorf("the slow consumer read as %+v: two closures, the one said twice counted once", a)
|
|
}
|
|
case "anchor.desk":
|
|
if a.Kind != AdvisoryConnectionClosed || a.Count != 1 || a.Reason != "Maximum Message Payload Exceeded" {
|
|
t.Errorf("the closed connection read as %+v", a)
|
|
}
|
|
default:
|
|
t.Errorf("an advisory for %s", a.User)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNatsBusFaultsReadNothingWhenTheBusModuleSaidNothing(t *testing.T) {
|
|
s := aBusWithEvents(t)
|
|
started := time.Now()
|
|
got, err := s.BusFaults(t.Context(), time.Now().Add(-time.Hour))
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("read %v, %v from a stream the bus module never wrote", got, err)
|
|
}
|
|
if took := time.Since(started); took > time.Second {
|
|
t.Fatalf("reading nothing took %s: a tick must not wait on an empty stream", took)
|
|
}
|
|
}
|
|
|
|
func TestTheControllersOwnSlowConsumerAndItsClosedConnectionAreOneAdvisory(t *testing.T) {
|
|
at := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
own := []Advisory{{Kind: AdvisorySlowConsumer, ID: "controller", Said: "the controller fell behind", First: at, Last: at, Count: 1}}
|
|
closed := BusClosures([]BusClosure{{User: "controller", CID: 5, Reason: "Slow Consumer (Pending Bytes)",
|
|
Opened: at.Add(-time.Hour), Closed: at.Add(time.Minute)}}, []string{AdvisorySlowConsumer}, at.Add(-time.Hour))
|
|
got := MergeAdvisories(own, closed)
|
|
if len(got) != 1 || got[0].Count != 1 || !got[0].Last.Equal(at.Add(time.Minute)) || got[0].User != "controller" {
|
|
t.Fatalf("merged as %+v: one fall behind, told to the client and closed by the server, is counted once", got)
|
|
}
|
|
}
|
|
|
|
// **A busy hour is read up to a bound, and says it was cut** (review of #326): a storm of closures must not
|
|
// spend a watchdog tick, and a count cut short is a floor, said as one.
|
|
func TestNatsBusFaultsReadAtMostTheBoundAndSaySo(t *testing.T) {
|
|
s := aBusWithEvents(t)
|
|
was := MaxBusEventsRead
|
|
MaxBusEventsRead = 2
|
|
t.Cleanup(func() { MaxBusEventsRead = was })
|
|
now := time.Now().UTC()
|
|
for i := range 3 {
|
|
sayClosure(t, s, BusSlowConsumerEvent, BusClosure{User: "laptop.claude-code", CID: uint64(10 + i),
|
|
Reason: "Slow Consumer (Pending Bytes)", Opened: now.Add(-time.Hour / 2), Closed: now.Add(-time.Duration(3-i) * time.Minute)})
|
|
}
|
|
got, err := s.BusFaults(t.Context(), now.Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].Count != 2 || !strings.Contains(got[0].Said, "at least: the read stopped at 2") {
|
|
t.Fatalf("a read past its bound read as %+v", got)
|
|
}
|
|
}
|