A launched bundle's mesh/subscribe binds the module's own durable consumer — EVENTS, <node>_<module>, by name as the module's own runtime bound it, so nothing is lost or replayed in the move — and every event goes to each child of the module that subscribed as mesh/event, acknowledged only when all answered, negatively acknowledged after a short delay when one failed or died, terminated when it is not an event. mesh/ask calls a tool as the module. Every launched bundle is started again when it exits, with backoff, since long-running code waits for no call. Requires SDK 0.1.6.
158 lines
5.4 KiB
Go
158 lines
5.4 KiB
Go
package runtime
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
"github.com/novox/mesh-tools/node-tools/internal/launch"
|
|
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
|
)
|
|
|
|
// A logger safe to write from the runtime's goroutines.
|
|
type lines struct {
|
|
mu sync.Mutex
|
|
l []string
|
|
}
|
|
|
|
func (s *lines) logf(format string, args ...any) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.l = append(s.l, strings.TrimSpace(sprintf(format, args...)))
|
|
}
|
|
|
|
func (s *lines) all() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return strings.Join(s.l, "\n")
|
|
}
|
|
|
|
func sprintf(format string, args ...any) string { return fmtSprintf(format, args...) }
|
|
|
|
func countLines(t *testing.T, path, want string) int {
|
|
t.Helper()
|
|
b, _ := os.ReadFile(path)
|
|
n := 0
|
|
for _, l := range strings.Split(string(b), "\n") {
|
|
if l == want {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// novox/hq ADR 0198: a module's long-running code is launched by the node's runtime, and the runtime
|
|
// is its bus — it binds the module's own consumer, hands each event to the bundle, and acknowledges it
|
|
// only when the bundle has taken it; one the bundle failed or died on is offered again.
|
|
func TestTheRuntimeHandsAModulesEventsToItsBundleAndAcknowledgesThemOnlyWhenTaken(t *testing.T) {
|
|
mesh := mt.New(t)
|
|
restore := bus.NakDelay
|
|
bus.NakDelay = 300 * time.Millisecond
|
|
t.Cleanup(func() { bus.NakDelay = restore })
|
|
firstPause := launch.RestartFirst
|
|
launch.RestartFirst = 100 * time.Millisecond
|
|
t.Cleanup(func() { launch.RestartFirst = firstPause })
|
|
|
|
mesh.Issue(t, mt.MembershipOf("watcher", "anchor", false, nil))
|
|
mesh.Issue(t, mt.MembershipOf("beta", "anchor", true, nil))
|
|
// The controller's consumer for the module, as its own runtime bound it: anchor_watcher on EVENTS.
|
|
mesh.Consumer(t, "anchor", "watcher", []string{"mesh.mod.alpha.event.>"}, 2*time.Second)
|
|
|
|
nodeTools := connect(t, "node-tools", "anchor")
|
|
asker := connect(t, "console", "workstation")
|
|
dir := t.TempDir()
|
|
log := filepath.Join(dir, "watch.log")
|
|
said := &lines{}
|
|
stop, err := Run(nodeTools, []Served{
|
|
{Module: "watcher", Entrypoints: []string{mt.Fixture("watcher.serve.mjs")}},
|
|
{Module: "beta", Entrypoints: []string{mt.Fixture("many-beta.serve.mjs")}},
|
|
}, map[string]map[string]string{"watcher": {"WATCH_LOG": log}}, said.logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(stop)
|
|
if !strings.Contains(said.all(), "watcher's events arrive on its consumer anchor_watcher") {
|
|
t.Fatalf("the module's consumer was not bound:\n%s", said.all())
|
|
}
|
|
|
|
// Handled once, acknowledged once.
|
|
mesh.Emit(t, "mesh.mod.alpha.event.happened", map[string]any{"n": 1})
|
|
mt.Until(t, func() error {
|
|
if countLines(t, log, "handled 1") != 1 {
|
|
return errorf("event 1 not handled yet")
|
|
}
|
|
return nil
|
|
})
|
|
// The handler fails the first time: not acknowledged, offered again, then handled.
|
|
mesh.Emit(t, "mesh.mod.alpha.event.happened", map[string]any{"n": 2, "fail": true})
|
|
// The bundle dies on the first offer: not acknowledged, the bundle is started again and the
|
|
// event is offered again once its ack wait has passed.
|
|
mesh.Emit(t, "mesh.mod.alpha.event.happened", map[string]any{"n": 3, "die": true})
|
|
deadline := time.Now().Add(20 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if countLines(t, log, "handled 2") == 1 && countLines(t, log, "handled 3") == 1 {
|
|
break
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
if countLines(t, log, "handled 2") != 1 || countLines(t, log, "handled 3") != 1 {
|
|
b, _ := os.ReadFile(log)
|
|
t.Fatalf("a failed or interrupted event was not offered again and handled once:\n%s\nruntime:\n%s", b, said.all())
|
|
}
|
|
if countLines(t, log, "started") < 2 {
|
|
t.Errorf("the bundle that died was not started again: %s", said.all())
|
|
}
|
|
mt.Until(t, func() error {
|
|
ack, notYet := mesh.Pending(t, "anchor", "watcher")
|
|
if ack != 0 || notYet != 0 {
|
|
return errorf("still pending: %d unacknowledged, %d undelivered", ack, notYet)
|
|
}
|
|
return nil
|
|
})
|
|
if countLines(t, log, "handled 1") != 1 {
|
|
t.Errorf("event 1 was handled more than once")
|
|
}
|
|
|
|
// A tool asks another module's tool through the runtime, as the module.
|
|
got, err := call(t, asker, "watcher.relay@anchor", map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same(t, got, `{"beta":3}`)
|
|
}
|
|
|
|
// A long-running bundle that exits is started again, without waiting for a call (ADR 0198).
|
|
func TestALongRunningBundleThatExitsIsStartedAgain(t *testing.T) {
|
|
mesh := mt.New(t)
|
|
firstPause := launch.RestartFirst
|
|
launch.RestartFirst = 100 * time.Millisecond
|
|
t.Cleanup(func() { launch.RestartFirst = firstPause })
|
|
mesh.Issue(t, mt.MembershipOf("flaky", "anchor", false, nil))
|
|
nodeTools := connect(t, "node-tools", "anchor")
|
|
log := filepath.Join(t.TempDir(), "flaky.log")
|
|
said := &lines{}
|
|
stop, err := Run(nodeTools, []Served{{Module: "flaky", Entrypoints: []string{mt.Fixture("flaky.serve.mjs")}}},
|
|
map[string]map[string]string{"flaky": {"FLAKY_LOG": log}}, said.logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(stop)
|
|
mt.Until(t, func() error {
|
|
if countLines(t, log, "started") < 3 {
|
|
return errorf("started %d time(s)", countLines(t, log, "started"))
|
|
}
|
|
return nil
|
|
})
|
|
if !strings.Contains(said.all(), "flaky's bundle exited (3)") || !strings.Contains(said.all(), "started again in") {
|
|
t.Errorf("the restart was not said:\n%s", said.all())
|
|
}
|
|
}
|
|
|
|
func fmtSprintf(format string, args ...any) string { return fmt.Sprintf(format, args...) }
|
|
func errorf(format string, args ...any) error { return fmt.Errorf(format, args...) }
|