DLV-W1: the watchman says an accepted entry of bus state the bus module said nothing of in five minutes, and a refused one, until created or no longer asked (ADR 0595 §4, design 25 § 12.2)

It consumes the indexer's manifest-accepted and the bus module's created and refused on its
durable consumers. The five-minute bound is a row of its signals table (a pulse key whose
expiry is the finding, read back at a restart), not a new state: the prepare's state
overdue would have kept the same keys with no expiry to act on.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 12:34:50 +01:00
parent 8f6562368c
commit fc150986b7
8 changed files with 369 additions and 2 deletions
@@ -0,0 +1,110 @@
package main
import (
"context"
"encoding/json"
"strings"
"sync"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-watchman/internal/watch"
)
// **What the watchman consumes** (its manifest's `consumes`; novox/hq ADR 0595 §4, design 25 § 12.2; DLV-W1): the indexer's
// manifest-accepted, on its durable consumer on EVENTS, and the bus module's created and refused, on its durable consumer
// on the bus module's own stream, which its membership names (Hears). Each consumer is the one the grants part makes; one
// not there yet is opened when the watchman's membership next changes, and one opened is never opened twice. An event is
// acknowledged once handed to the watchman, so one given while it was down is heard when it is back.
// Consumed event names, as the consumes name them (the emitter is read from the subject's emitter token).
const (
eventAccepted = "manifest-accepted"
eventCreated = "created"
eventRefused = "refused"
)
// consumed hands one consumed event to the watchman, by its subject: manifest-accepted from EVENTS, created and refused
// from the bus module's stream. Anything else is left.
func consumed(w *watch.Watchman, subject string, body []byte, logf func(string, ...any)) {
switch {
case strings.HasSuffix(subject, ".event."+eventAccepted) && strings.HasPrefix(subject, "mesh.mod."):
var a delivery.ManifestAccepted
if err := json.Unmarshal(body, &a); err != nil || a.Module == "" {
logf("a manifest-accepted that does not read is left: %v", err)
return
}
w.Accepted(a)
case strings.HasPrefix(subject, "mesh.stream.") && strings.HasSuffix(subject, ".event."+eventCreated):
if s, err := watch.ReadBrokerSaid(body); err != nil {
logf("%v; left", err)
} else {
w.BrokerCreated(s)
}
case strings.HasPrefix(subject, "mesh.stream.") && strings.HasSuffix(subject, ".event."+eventRefused):
if s, err := watch.ReadBrokerSaid(body); err != nil {
logf("%v; left", err)
} else {
w.BrokerRefused(s)
}
}
}
// hearConsumed opens the watchman's durable consumers: on EVENTS now, and on each module stream its membership names as
// that membership arrives. A consumer the bus does not hold yet is said and tried again at the membership's next change.
func hearConsumed(ctx context.Context, js jetstream.JetStream, cred process.Account, w *watch.Watchman, logf func(string, ...any)) {
var mu sync.Mutex
opened := map[string]bool{}
open := func(stream, name string) {
mu.Lock()
defer mu.Unlock()
if opened[stream] {
return
}
cons, err := js.Consumer(ctx, stream, name)
if err != nil {
logf("the consumer %s on %s cannot be bound yet (%v): what it carries is heard once the grants part has made it", name, stream, err)
return
}
if _, err := process.HearEvents(cons, func(m jetstream.Msg) {
consumed(w, m.Subject(), m.Data(), logf)
_ = m.Ack()
}, logf); err != nil {
logf("the consumer %s on %s cannot be heard (%v)", name, stream, err)
return
}
opened[stream] = true
}
open("EVENTS", cred.EventConsumerName())
kv, err := js.KeyValue(ctx, membership.GrantsBucket)
if err != nil {
logf("its own membership cannot be read (%v): the bus module's created and refused are not heard until the watchman starts again", err)
return
}
watcher, err := kv.Watch(ctx, membership.GrantsKey(cred.Node, cred.Module))
if err != nil {
logf("its own membership cannot be watched (%v): the bus module's created and refused are not heard until the watchman starts again", err)
return
}
go func() {
defer watcher.Stop()
for e := range watcher.Updates() {
if e == nil || e.Operation() != jetstream.KeyValuePut {
continue
}
m, err := membership.DecodeGrantsMembership(e.Value())
if err != nil {
logf("its own membership does not read (%v): left", err)
continue
}
open("EVENTS", cred.EventConsumerName())
for _, h := range m.Hears {
open(h.Stream, h.Consumer)
}
}
}()
}
@@ -379,6 +379,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err := hear(conn, w, dead, logger.Printf); err != nil {
return err
}
hearConsumed(ctx, js, cred, w, logger.Printf)
errs := make(chan error, 16)
if healthWatch != nil {
@@ -7,9 +7,12 @@ import (
"io"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-watchman/internal/watch"
"github.com/nats-io/nats.go/jetstream"
)
@@ -45,6 +48,24 @@ func TestRunNeedsItsCredential(t *testing.T) { // 1135/AC6
}
}
})
t.Run("DLV-W1: the consumed events reach the watchman by their subjects", func(t *testing.T) {
out := &findingsOut{findings: map[string]bool{}}
w := watch.New(out, time.Now)
say := func(string, ...any) {}
consumed(w, "mesh.stream.nats.event.refused", []byte(`{"module":"m","local":"l","why":"no room"}`), say)
if !out.findings[watch.FindingKey(watch.ScopeModule, "m", watch.KindBusStateRefused+"-l")] {
t.Fatalf("refused reached no finding: %v", out.findings)
}
consumed(w, "mesh.mod.mesh-delivery-indexer.event.manifest-accepted", []byte(`{"module":"m","digest":"d","bus-state":[]}`), say)
if len(out.findings) != 0 {
t.Fatalf("an accepted manifest no longer asking for l left %v", out.findings)
}
consumed(w, "mesh.stream.nats.event.refused", []byte(`{"module":"m","local":"l"}`), say)
consumed(w, "mesh.stream.nats.event.created", []byte(`{"module":"m","local":"l"}`), say)
if len(out.findings) != 0 {
t.Fatalf("created left %v", out.findings)
}
})
err := run(context.Background(), func(string) string { return "" }, io.Discard)
if err == nil || !strings.Contains(err.Error(), "MESH_BROKER_FILE") {
t.Fatalf("run = %v, want a refusal naming MESH_BROKER_FILE", err)
@@ -55,3 +76,13 @@ func TestRunNeedsItsCredential(t *testing.T) { // 1135/AC6
type refusingBus struct{ err error }
func (r refusingBus) KeyValue(context.Context, string) (jetstream.KeyValue, error) { return nil, r.err }
// findingsOut keeps which findings stand.
type findingsOut struct{ findings map[string]bool }
func (o *findingsOut) PutPresence(string, bool) error { return nil }
func (o *findingsOut) PutPulse(string, []byte, time.Duration) error { return nil }
func (o *findingsOut) DeletePulse(string) error { return nil }
func (o *findingsOut) PutStuck(string, []byte) error { return nil }
func (o *findingsOut) PutFinding(key string, _ []byte) error { o.findings[key] = true; return nil }
func (o *findingsOut) DeleteFinding(key string) error { delete(o.findings, key); return nil }
@@ -0,0 +1,159 @@
package watch
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A refused or overdue creation of bus state is loud** (novox/hq ADR 0595 §4; design 25 § 12.2; DLV-W1). The watchman
// hears, through its `consumes`, the indexer's `manifest-accepted` (which carries the module's bus-state entries whole)
// and the bus module's own `created` and `refused`. An entry an accepted manifest asks for that has no `created` or
// `refused` within OverdueBound is the finding bus-state-overdue, naming the module and the entry; a `refused` is the
// finding bus-state-refused, with why. A later `created` clears both, and so does a later accepted manifest of that
// module that no longer asks for the entry. Never the bus module's to raise: it cannot while it is down.
//
// The bound is a row of the signals table: a pulse key per entry with OverdueBound to live, whose expiry is the finding,
// so no loop looks and a pulse that stood across a restart still acts. Both findings outlive a restart (signalKind),
// since what ends them is heard, not read.
// OverdueBound is how long an accepted entry may wait for the bus module's created or refused.
const OverdueBound = 5 * time.Minute
// The kinds, and the signals table's row.
const (
KindBusStateOverdue = "bus-state-overdue"
KindBusStateRefused = "bus-state-refused"
rowBusState = "bus-state"
)
// BrokerSaid is the part of the bus module's created or refused the watchman reads: the module, the entry, and why a
// create was refused. It stands until the seat mesh-broker's definition is generated in mesh-contracts.
type BrokerSaid struct {
Module string `json:"module"`
Local string `json:"local"`
Why string `json:"why,omitempty"`
}
// busStateID is the signal's and the findings' id of one entry: `<module>.<local>`.
func busStateID(module, local string) string { return module + "." + local }
func overdueKey(module, local string) string {
return FindingKey(ScopeModule, module, KindBusStateOverdue+"-"+local)
}
func refusedKey(module, local string) string {
return FindingKey(ScopeModule, module, KindBusStateRefused+"-"+local)
}
// Accepted is the indexer's manifest-accepted heard: each entry it asks for waits OverdueBound for the bus module's word,
// and every entry of the module this watchman waits on or holds a finding of that it no longer asks for is ended.
func (w *Watchman) Accepted(a delivery.ManifestAccepted) {
w.mu.Lock()
defer w.mu.Unlock()
asked := map[string]bool{}
for _, e := range a.BusState {
if e.Name == "" {
continue
}
asked[e.Name] = true
// One already said created or refused since an earlier verdict waits again: a new verdict asks the bus module again.
w.signal(rowBusState, busStateID(a.Module, e.Name), OverdueBound, signalDue{Do: dueFind, Finding: Finding{
Scope: ScopeModule, ID: a.Module, Token: KindBusStateOverdue + "-" + e.Name, Kind: KindBusStateOverdue,
Severity: Warning, Source: rowBusState, Since: w.now().Add(OverdueBound).UTC().Truncate(time.Second),
Summary: fmt.Sprintf("the bus module said nothing of %s's %s (%s) within %s of its accepted manifest %s", a.Module,
e.Name, e.Storage, OverdueBound, short(a.Digest)),
Headline: "The bus state " + e.Name + " of " + a.Module + " was not created",
Explanation: "The indexer accepted a manifest of " + a.Module + " asking the bus module for " + e.Name + ", and the bus " +
"module said neither created nor refused within " + OverdueBound.String() + ": it is down, or behind. " + a.Module +
" fails at its start while the entry's binding is missing (novox/hq ADR 0595 §1).",
Needs: "a look at the bus module's health and its process's log; nats_first_view_again asks it to read the verdicts again",
Resolved: "the bus module said " + e.Name + " of " + a.Module + " created, or a later manifest no longer asks for it"}})
}
for _, local := range w.busStateEntriesOf(a.Module) {
if asked[local] {
continue
}
w.unsignal(rowBusState, busStateID(a.Module, local))
w.unfind(overdueKey(a.Module, local))
w.unfind(refusedKey(a.Module, local))
}
}
// BrokerCreated is the bus module's created heard: the entry's wait ends, and its findings with it.
func (w *Watchman) BrokerCreated(s BrokerSaid) {
w.mu.Lock()
defer w.mu.Unlock()
w.unsignal(rowBusState, busStateID(s.Module, s.Local))
w.unfind(overdueKey(s.Module, s.Local))
w.unfind(refusedKey(s.Module, s.Local))
}
// BrokerRefused is the bus module's refused heard: the entry's wait ends in the finding bus-state-refused, until a later
// created.
func (w *Watchman) BrokerRefused(s BrokerSaid) {
w.mu.Lock()
defer w.mu.Unlock()
w.unsignal(rowBusState, busStateID(s.Module, s.Local))
w.unfind(overdueKey(s.Module, s.Local))
why := strings.TrimSpace(s.Why)
if why == "" {
why = "it gave no reason"
}
w.find(Finding{Scope: ScopeModule, ID: s.Module, Token: KindBusStateRefused + "-" + s.Local, Kind: KindBusStateRefused,
Severity: Urgent, Source: rowBusState, Since: w.now().UTC().Truncate(time.Second), Said: why,
Summary: fmt.Sprintf("the bus module refused to create %s's %s: %s", s.Module, s.Local, why),
Headline: "The bus state " + s.Local + " of " + s.Module + " was refused",
Explanation: "The bus module could not create " + s.Local + " for " + s.Module + ", so no binding is handed and " + s.Module +
" fails at its start, naming the entry (novox/hq ADR 0595 §1): " + why,
Needs: "a pull request that mends the entry in the manifest of " + s.Module + ", or the bus module's cause fixed",
Resolved: "the bus module said " + s.Local + " of " + s.Module + " created"})
}
// busStateEntriesOf are the entries of module this watchman waits on or holds a finding of, by local name, sorted.
func (w *Watchman) busStateEntriesOf(module string) []string {
seen := map[string]bool{}
prefix := signalKey(rowBusState, module) + "."
for pk := range w.signals {
if local, ok := strings.CutPrefix(pk, prefix); ok && !strings.Contains(local, ".") {
seen[local] = true
}
}
for _, kind := range []string{KindBusStateOverdue, KindBusStateRefused} {
p := FindingKey(ScopeModule, module, kind+"-")
for key := range w.findings {
if local, ok := strings.CutPrefix(key, p); ok {
seen[local] = true
}
}
}
out := make([]string, 0, len(seen))
for l := range seen {
out = append(out, l)
}
sort.Strings(out)
return out
}
// ReadBrokerSaid reads the bus module's created or refused.
func ReadBrokerSaid(body []byte) (BrokerSaid, error) {
var s BrokerSaid
if err := json.Unmarshal(body, &s); err != nil {
return BrokerSaid{}, fmt.Errorf("the bus module's word cannot be read: %w", err)
}
if s.Module == "" || s.Local == "" {
return BrokerSaid{}, fmt.Errorf("the bus module's word names no module or no entry")
}
return s, nil
}
func short(digest string) string {
if len(digest) > 12 {
return digest[:12]
}
return digest
}
@@ -6,10 +6,20 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
func mustEncode(t *testing.T, f Finding) []byte {
t.Helper()
v, err := EncodeFinding(f)
if err != nil {
t.Fatal(err)
}
return v
}
func haveOf(t *testing.T, phase delivery.Phase, health delivery.Health, detail string) []byte {
t.Helper()
b, err := delivery.EncodeHave(delivery.Have{FP: fpNew, Phase: phase, Health: health, Detail: detail})
@@ -72,6 +82,52 @@ func TestTheWatchmansFindings(t *testing.T) {
}
w.ModuleVerdict("nats", value(""), Put)
}, map[string]string{}},
{"DLV-W1, 1966/AC37-AC38 (ADR 0595 §4, design 25 § 12.2): an accepted entry the bus module says nothing of in five minutes is overdue until created; a refused one stands until created; a later verdict no longer asking ends both", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
accepted := func(locals ...string) delivery.ManifestAccepted {
a := delivery.ManifestAccepted{Module: "mesh-knowledge", Digest: strings.Repeat("d", 64)}
for _, l := range locals {
a.BusState = append(a.BusState, catalogue.BusStateEntry{Name: l, Storage: catalogue.StorageLedger})
}
return a
}
w.Accepted(accepted("records", "drafts", "kept"))
pk := func(local string) string { return signalKey(rowBusState, busStateID("mesh-knowledge", local)) }
if o.ttls[pk("records")] != OverdueBound {
t.Fatalf("no five-minute pulse for records: %v", o.ttls)
}
w.BrokerCreated(BrokerSaid{Module: "mesh-knowledge", Local: "drafts"})
expire(w, c, pk("drafts"), 5*time.Minute) // created in time: no finding
expire(w, c, pk("records"), 0)
overdue := o.findings[overdueKey("mesh-knowledge", "records")]
if overdue.Kind != KindBusStateOverdue || !strings.Contains(overdue.Summary, "mesh-knowledge's records") {
t.Fatalf("findings = %v", o.findings)
}
w.BrokerCreated(BrokerSaid{Module: "mesh-knowledge", Local: "records"})
if _, open := o.findings[overdueKey("mesh-knowledge", "records")]; open {
t.Fatal("created did not clear overdue")
}
w.BrokerRefused(BrokerSaid{Module: "mesh-knowledge", Local: "kept", Why: "the cap is above the mesh's bound"})
if f := o.findings[refusedKey("mesh-knowledge", "kept")]; f.Severity != Urgent || !strings.Contains(f.Summary, "cap is above") {
t.Fatalf("findings = %v", o.findings)
}
// A restart keeps both kinds: what ends them is heard.
if !signalKind(mustEncode(t, o.findings[refusedKey("mesh-knowledge", "kept")])) {
t.Error("a refused creation would be dropped at a restart")
}
// A later verdict asking for kept alone again waits on it again; one no longer asking for it ends it.
w.Accepted(accepted("kept", "fresh"))
w.Accepted(accepted("fresh"))
if _, open := o.findings[refusedKey("mesh-knowledge", "kept")]; open {
t.Fatal("a verdict no longer asking for kept left its finding")
}
if _, waits := o.pulses[pk("kept")]; waits {
t.Fatal("a verdict no longer asking for kept left its wait")
}
w.BrokerCreated(BrokerSaid{Module: "mesh-knowledge", Local: "fresh"})
// A refused create stands until created.
w.Accepted(accepted("gone"))
w.BrokerRefused(BrokerSaid{Module: "mesh-knowledge", Local: "gone"})
}, map[string]string{refusedKey("mesh-knowledge", "gone"): Urgent}},
{"ADR 0601 §4: what a node keeps of a module as it runs, for a manifest that does not say it yet, is a warning until the have says none", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
have := func(kept ...string) []byte {
b, err := delivery.EncodeHave(delivery.Have{FP: "src2:" + strings.Repeat("a", 64), Phase: delivery.PhaseRunning, Health: delivery.HealthOK, Kept: kept})
@@ -366,7 +366,7 @@ func signalKind(value []byte) bool {
return false
}
switch f.Source {
case "provisioner.failing", "S8", "S9", "S10", "S11":
case "provisioner.failing", "S8", "S9", "S10", "S11", rowBusState:
return true
}
return false
@@ -47,6 +47,7 @@ type manifest struct {
Module string `json:"module"`
Claims []claim `json:"claims"`
Tools []string `json:"tools"`
Consumes []string `json:"consumes"`
Moved bool `json:"moved"`
Version string `json:"version"`
Slug string `json:"slug"`
@@ -101,7 +102,11 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6
if !reflect.DeepEqual(m.Tools, []string{"watchman_findings"}) {
t.Errorf("tools = %v", m.Tools)
}
for _, k := range []string{"emits", "context", "items", "consumes", "provides"} {
// DLV-W1 (ADR 0595 §4): it hears the accepted verdicts and the bus module's created and refused, durably.
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-indexer.manifest-accepted", "nats.created", "nats.refused"}) {
t.Errorf("consumes = %v", m.Consumes)
}
for _, k := range []string{"emits", "context", "items", "provides"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the watchman has none", k)
}
@@ -29,6 +29,11 @@
"tools": [
"watchman_findings"
],
"consumes": [
"mesh-delivery-indexer.manifest-accepted",
"nats.created",
"nats.refused"
],
"state": [
{ "name": "presence", "history": 20 },
{ "name": "pulse", "key-ttl": true },