Merge pull request 'The controller takes what the watchman says: key-ttl buckets, the checks running readable, stuck keys raised, silence from presence (design 47 R2b)' (#388) from feat/delivery-watch into main

This commit was merged in pull request #388.
This commit is contained in:
2026-10-18 23:34:07 +00:00
32 changed files with 1010 additions and 81 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ const packageManagerKey = "the package manager ("
func watchApplyFailing(f *signalFacts) []conditions.Observation { func watchApplyFailing(f *signalFacts) []conditions.Observation {
heard := map[string]bool{} heard := map[string]bool{}
for _, m := range f.machines { for _, m := range f.machines {
if !m.asleep() && !m.lastHeard.IsZero() && f.now.Sub(m.lastHeard) <= heartbeatBound(m.every) { if !m.asleep() && !m.lastHeard.IsZero() && !m.silent(f.now) {
heard[m.name] = true heard[m.name] = true
} }
} }
+9
View File
@@ -120,6 +120,11 @@ var probeRegistry = []probe{
{ID: probeDeliveriesID, Asserts: "no delivery is held past its state's bound unsaid: mesh-delivery's " + {ID: probeDeliveriesID, Asserts: "no delivery is held past its state's bound unsaid: mesh-delivery's " +
"`stalled`, each with the transition its table lets healer H2 take", From: "ADR 0239", "`stalled`, each with the transition its table lets healer H2 take", From: "ADR 0239",
Kind: kindDeliveryStalled, Phase: 3, run: probeDeliveries}, Kind: kindDeliveryStalled, Phase: 3, run: probeDeliveries},
// What the watchman says stopped moving (ADR 0433 §2, issue 1156): each key of its stuck bucket not moved, but a
// node's presence, which the silent check takes.
{ID: probeStuckID, Asserts: "no key the watchman says stopped moving is left unsaid: each key of its stuck bucket " +
"not moved is a condition of the delivery scope until it says moved", From: "ADR 0433 §2, issue 1156",
Kind: kindStuck, Phase: 3, run: probeStuck},
// A first merge into a repository the mesh builds nothing from, kept waiting for mesh-delivery's word on the // A first merge into a repository the mesh builds nothing from, kept waiting for mesh-delivery's word on the
// operator's Approve (novox/hq ADR 0432, issue 1146): said past a bound, never only in a log line (ADR 0426 §3, §8). // operator's Approve (novox/hq ADR 0432, issue 1146): said past a bound, never only in a log line (ADR 0426 §3, §8).
{ID: probeFirstBuildsID, Asserts: "no first merge into a repository the mesh builds nothing from waits more than ten " + {ID: probeFirstBuildsID, Asserts: "no first merge into a repository the mesh builds nothing from waits more than ten " +
@@ -183,6 +188,10 @@ var probeRegistry = []probe{
{ID: backupHeldProbe, Asserts: "no delivery plan is held for a backup before its send that failed or did not " + {ID: backupHeldProbe, Asserts: "no delivery plan is held for a backup before its send that failed or did not " +
"answer in ten minutes", From: "ADR 0404 decision 4, issue 1066", Kind: kindBackupHeld, Phase: 1, "answer in ten minutes", From: "ADR 0404 decision 4, issue 1066", Kind: kindBackupHeld, Phase: 1,
run: probeBackupHeld}, run: probeBackupHeld},
// A plan's send key the bus refused (ADR 0433 §4, issue 1157): in place of the timer that stood in for it.
{ID: sendKeyRefusedID, Asserts: "no delivery plan's key that watches a machine for its report was refused by the " +
"bus at the newest look", From: "ADR 0433 §4, issue 1157", Kind: kindSendKeyRefused, Phase: 1,
run: probeSendKeyRefused},
} }
// probeVerdict is one probe's outcome in a run. // probeVerdict is one probe's outcome in a run.
+13
View File
@@ -610,6 +610,19 @@ var plainWordings = map[string]func(conditions.Observation) words{
"did not answer in ten minutes, so nothing of the delivery is built or sent.", "did not answer in ten minutes, so nothing of the delivery is built or sent.",
Resolved: "Resolved: the backup is done and the delivery goes on"} Resolved: "Resolved: the backup is done and the delivery goes on"}
}), }),
kindStuck: worded(func(o conditions.Observation) words {
return words{Headline: "Something in a delivery has stopped moving",
Explanation: "The watchman noticed a part of the delivery that did not move when it should have: a build " +
"asked and not answered, a check that stopped, or a part that went offline. Nothing is lost, and it is " +
"said again until it moves.",
Resolved: "Resolved: it moves again"}
}),
kindSendKeyRefused: worded(func(o conditions.Observation) words {
return words{Headline: "A delivery cannot watch a machine for its report",
Explanation: "The bus refused the note that makes the mesh notice a machine that never reports on a send. " +
"The delivery goes on, and the note is written again at its next look.",
Resolved: "Resolved: the delivery watches the machine for its report again"}
}),
kindConsumerBehind: worded(func(o conditions.Observation) words { kindConsumerBehind: worded(func(o conditions.Observation) words {
return words{Headline: "Messages pile up for a listener", return words{Headline: "Messages pile up for a listener",
Explanation: "One of the mesh's listeners on the bus is far behind, so what it handles happens late. A " + Explanation: "One of the mesh's listeners on the bus is far behind, so what it handles happens late. A " +
+13 -33
View File
@@ -400,34 +400,39 @@ func syncSilentKeys(ctx context.Context, open *stores) error {
} }
// keepSilentKeys puts the keys on the bus as the waits are. Where the bus will not take a key — no bucket, the keys // keepSilentKeys puts the keys on the bus as the waits are. Where the bus will not take a key — no bucket, the keys
// cannot be read, one cannot be put — the wait is looked at by a timer of its own, once, and the error is given back so // cannot be read, one cannot be put — the refusal is raised as a condition naming the plan and the machine, never
// the look is retried. // stood in for by a timer, and the error is given back so the look is retried and writes the key again (novox/hq ADR
// 0433 §4). The set of refusals is what this look found: a key written at the next look is no longer in it.
func keepSilentKeys(ctx context.Context, c silentClock, want map[silentWait]time.Time, now time.Time) error { func keepSilentKeys(ctx context.Context, c silentClock, want map[silentWait]time.Time, now time.Time) error {
byTimer := func() { refused := func(err error) map[silentWait]error {
for w, sent := range want { out := map[silentWait]error{}
silentByTimer(w, sent, silentTTL(sent, now)) for w := range want {
out[w] = err
} }
return out
} }
if c == nil { if c == nil {
byTimer() noteRefusedSendKeys(refused(errors.New("the bus's clock for the waits is not there")))
return nil return nil
} }
c = boundedClock{c} c = boundedClock{c}
held, err := c.Held(ctx) held, err := c.Held(ctx)
if err != nil { if err != nil {
byTimer() noteRefusedSendKeys(refused(err))
return err return err
} }
arm, drop := silentDiff(want, held) arm, drop := silentDiff(want, held)
var first error var first error
notTaken := map[silentWait]error{}
for _, w := range arm { for _, w := range arm {
if err := c.Arm(ctx, w, silentTTL(want[w], now), want[w]); err != nil { if err := c.Arm(ctx, w, silentTTL(want[w], now), want[w]); err != nil {
silentByTimer(w, want[w], silentTTL(want[w], now)) notTaken[w] = err
if first == nil { if first == nil {
first = err first = err
} }
} }
} }
noteRefusedSendKeys(notTaken)
for _, w := range drop { for _, w := range drop {
if err := c.Reported(ctx, w); err != nil && first == nil { if err := c.Reported(ctx, w); err != nil && first == nil {
first = err first = err
@@ -481,31 +486,6 @@ func (b boundedClock) Reported(ctx context.Context, w silentWait) error {
return err return err
} }
var silentTimers = struct {
mu sync.Mutex
timers map[silentWait]time.Time
}{timers: map[silentWait]time.Time{}}
// silentByTimer looks at the plans when a wait's time is up, for a process whose bus will not take the key
// (DegradableGrants says why that can be): never silently never. Once for each send.
func silentByTimer(w silentWait, sent time.Time, ttl time.Duration) {
silentTimers.mu.Lock()
defer silentTimers.mu.Unlock()
if at, ok := silentTimers.timers[w]; ok && at.Equal(sent) {
return
}
silentTimers.timers[w] = sent
// retries: a rollout's wait for a machine, where the bus would not take its key. One per send, once.
time.AfterFunc(ttl, func() {
silentTimers.mu.Lock()
if silentTimers.timers[w].Equal(sent) {
delete(silentTimers.timers, w)
}
silentTimers.mu.Unlock()
askToLook()
})
}
// startRolloutSilent puts the wait for the machines' reports on the bus for as long as the context lives: the // startRolloutSilent puts the wait for the machines' reports on the bus for as long as the context lives: the
// controller is the lease holder here, the only one that writes it. The watcher asks for a look when a key expires. // controller is the lease holder here, the only one that writes it. The watcher asks for a look when a key expires.
func startRolloutSilent(ctx context.Context, js *broker.JetStream) error { func startRolloutSilent(ctx context.Context, js *broker.JetStream) error {
+69 -14
View File
@@ -3,10 +3,12 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
) )
@@ -159,22 +161,76 @@ func (brokenClock) Held(context.Context) (map[silentWait]time.Time, error) {
return nil, errors.New("the bus is down") return nil, errors.New("the bus is down")
} }
// A bus that will not say which keys it holds does not leave the waits unwatched: each is looked at by a timer of its own // 1157/AC1: a bus that will not say which keys it holds, or will not take one, does not leave the waits unwatched by
// and the error is given back, so the look is marked failed and retried (issue 974, review 1). // nothing: the refusal raises a condition naming the plan and the machine, no timer stands in for the key, and the error
func TestKeysThatCannotBeReadAreWatchedByTimersAndTheLookFails(t *testing.T) { // is given back so the look is marked failed and retried (issue 974, review 1; ADR 0433 §4).
func TestARefusedSendKeyRaisesAConditionNamingThePlanAndTheMachine(t *testing.T) { // 1157/AC1
w := silentWait{Plan: "plan-held", Tier: 0, Node: "ace"} w := silentWait{Plan: "plan-held", Tier: 0, Node: "ace"}
sent := time.Now().UTC() sent := time.Now().UTC()
t.Cleanup(func() { silentTimers.mu.Lock(); delete(silentTimers.timers, w); silentTimers.mu.Unlock() }) for name, clock := range map[string]silentClock{"keys cannot be read": brokenClock{}, "a key cannot be put": armRefusingClock{},
err := keepSilentKeys(t.Context(), brokenClock{}, map[silentWait]time.Time{w: sent}, sent) "no clock": nil} {
if err == nil { t.Run(name, func(t *testing.T) {
t.Fatal("no error given back") noteRefusedSendKeys(nil)
t.Cleanup(func() { noteRefusedSendKeys(nil) })
err := keepSilentKeys(t.Context(), clock, map[silentWait]time.Time{w: sent}, sent)
if clock != nil && err == nil {
t.Fatal("no error given back")
}
got := observeRefusedSendKeys()
if len(got) != 1 {
t.Fatalf("observations %+v", got)
}
o := got[0]
if o.Scope != conditions.ScopePlan || o.ID != "plan-held" || o.Machine != "ace" || o.Kind != kindSendKeyRefused ||
!strings.Contains(o.Summary, "plan-held") || !strings.Contains(o.Summary, "ace") {
t.Fatalf("the condition does not name the plan and the machine: %+v", o)
}
})
} }
silentTimers.mu.Lock() }
armed := silentTimers.timers[w].Equal(sent)
silentTimers.mu.Unlock() // 1157/AC2: at the plan's next look the send key is written again, and the condition clears once it is.
if !armed { func TestTheNextLookWritesTheRefusedSendKeyAndTheConditionClears(t *testing.T) { // 1157/AC2
t.Error("no timer of its own watches the wait") w := silentWait{Plan: "plan-held", Tier: 0, Node: "ace"}
sent := time.Now().UTC()
noteRefusedSendKeys(nil)
t.Cleanup(func() { noteRefusedSendKeys(nil) })
want := map[silentWait]time.Time{w: sent}
if err := keepSilentKeys(t.Context(), armRefusingClock{}, want, sent); err == nil || len(observeRefusedSendKeys()) != 1 {
t.Fatalf("the refusal was not raised: %v %+v", err, observeRefusedSendKeys())
} }
clock := &keptClock{}
if err := keepSilentKeys(t.Context(), clock, want, sent); err != nil {
t.Fatal(err)
}
if !clock.armed[w].Equal(sent) {
t.Fatalf("the next look did not write the key again: %v", clock.armed)
}
if got := observeRefusedSendKeys(); len(got) != 0 {
t.Fatalf("the condition stays after the key was written: %+v", got)
}
}
type armRefusingClock struct{ silentClock }
func (armRefusingClock) Held(context.Context) (map[silentWait]time.Time, error) { return nil, nil }
func (armRefusingClock) Arm(context.Context, silentWait, time.Duration, time.Time) error {
return errors.New("the bus refuses the key")
}
// keptClock is a clock that takes every key.
type keptClock struct {
silentClock
armed map[silentWait]time.Time
}
func (c *keptClock) Held(context.Context) (map[silentWait]time.Time, error) { return nil, nil }
func (c *keptClock) Arm(_ context.Context, w silentWait, _ time.Duration, sent time.Time) error {
if c.armed == nil {
c.armed = map[silentWait]time.Time{}
}
c.armed[w] = sent
return nil
} }
// A plan moved by a process the serving controller did not spawn — a verb run at the control node's terminal — is // A plan moved by a process the serving controller did not spawn — a verb run at the control node's terminal — is
@@ -189,14 +245,13 @@ func (hungClock) Held(context.Context) (map[silentWait]time.Time, error) {
} }
// issue 1121: the look holds the plans while it keeps the silent keys, so a key listing that never ends held the plans // issue 1121: the look holds the plans while it keeps the silent keys, so a key listing that never ends held the plans
// and every node for good. Every call to the clock has a bound, and the wait is looked at by a timer of its own. // and every node for good. Every call to the clock has a bound, and the refusal is raised as a condition.
func TestAClockThatNeverAnswersDoesNotHoldTheLookForever(t *testing.T) { func TestAClockThatNeverAnswersDoesNotHoldTheLookForever(t *testing.T) {
was := silentCallBound was := silentCallBound
silentCallBound = 50 * time.Millisecond silentCallBound = 50 * time.Millisecond
t.Cleanup(func() { silentCallBound = was }) t.Cleanup(func() { silentCallBound = was })
w := silentWait{Plan: "plan-hung", Tier: 0, Node: "ace"} w := silentWait{Plan: "plan-hung", Tier: 0, Node: "ace"}
sent := time.Now().UTC() sent := time.Now().UTC()
t.Cleanup(func() { silentTimers.mu.Lock(); delete(silentTimers.timers, w); silentTimers.mu.Unlock() })
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- keepSilentKeys(t.Context(), hungClock{}, map[silentWait]time.Time{w: sent}, sent) }() go func() { done <- keepSilentKeys(t.Context(), hungClock{}, map[silentWait]time.Time{w: sent}, sent) }()
select { select {
+1 -1
View File
@@ -629,7 +629,7 @@ func heardMachines(d *doctor) map[string]bool {
return out return out
} }
for _, m := range f.machines { for _, m := range f.machines {
if !m.lastHeard.IsZero() && f.now.Sub(m.lastHeard) <= heartbeatBound(m.every) && !m.asleep() { if !m.lastHeard.IsZero() && !m.silent(f.now) && !m.asleep() {
out[m.name] = true out[m.name] = true
} }
} }
@@ -42,7 +42,7 @@ func silentOnABus(t *testing.T) (*busSilent, <-chan silentWait) {
return s, heard return s, heard
} }
func TestAKeyLeftAloneExpiresAndTheWatcherNamesTheMachineSilent(t *testing.T) { func TestAKeyLeftAloneExpiresAndTheWatcherNamesTheMachineSilent(t *testing.T) { // 1157/AC3: the send keys stay until release 6 (ADR 0433 §5)
s, heard := silentOnABus(t) s, heard := silentOnABus(t)
want := silentWait{Plan: "plan.7", Tier: 2, Node: "ace"} want := silentWait{Plan: "plan.7", Tier: 2, Node: "ace"}
if err := s.Arm(t.Context(), want, time.Second, time.Now()); err != nil { if err := s.Arm(t.Context(), want, time.Second, time.Now()); err != nil {
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"fmt"
"sort"
"sync"
"github.com/novox/mesh-controller/internal/conditions"
)
// A plan's send key the bus refuses (novox/hq ADR 0433 §4, issue 1157): the look that could not write it says which, and
// a probe of the controller's self-check raises each as a condition naming the plan and the machine. The next look writes
// the key again; the look that writes it leaves it out of the set, and the condition clears.
const (
kindSendKeyRefused = "send-key-refused"
sendKeyRefusedID = "DL"
)
var refusedSendKeys = struct {
mu sync.Mutex
by map[silentWait]error
}{}
// noteRefusedSendKeys sets the keys the newest look could not write, replacing what the look before found.
func noteRefusedSendKeys(refused map[silentWait]error) {
refusedSendKeys.mu.Lock()
defer refusedSendKeys.mu.Unlock()
refusedSendKeys.by = refused
}
// observeRefusedSendKeys is one condition per plan and machine whose send key was refused.
func observeRefusedSendKeys() []conditions.Observation {
refusedSendKeys.mu.Lock()
waits := make([]silentWait, 0, len(refusedSendKeys.by))
why := map[silentWait]error{}
for w, err := range refusedSendKeys.by {
waits = append(waits, w)
why[w] = err
}
refusedSendKeys.mu.Unlock()
sort.Slice(waits, func(i, j int) bool { return silentKey(waits[i]) < silentKey(waits[j]) })
seen := map[[2]string]bool{}
var out []conditions.Observation
for _, w := range waits {
if seen[[2]string{w.Plan, w.Node}] {
continue // a plan sends a machine one tier at a time; one condition says it
}
seen[[2]string{w.Plan, w.Node}] = true
out = append(out, conditions.Observation{Scope: conditions.ScopePlan, ID: w.Plan, Token: kindSendKeyRefused + "-" + w.Node,
Kind: kindSendKeyRefused, Machine: w.Node, Severity: conditions.Warning,
Summary: fmt.Sprintf("the bus refused the key that watches the delivery %s for a report from %s, so nothing "+
"names the machine silent if it never reports; the plan writes the key again at its next look", w.Plan, w.Node),
Said: fmt.Sprintf("%s: %v", silentKey(w), why[w])})
}
return out
}
// probeSendKeyRefused reads what the newest look of the plans could not write.
func probeSendKeyRefused(context.Context, *doctor) ([]conditions.Observation, error) {
return observeRefusedSendKeys(), nil
}
+2 -2
View File
@@ -335,7 +335,7 @@ func watchHeartbeats(f *signalFacts) []conditions.Observation {
} }
bound := heartbeatBound(m.every) bound := heartbeatBound(m.every)
silent := f.now.Sub(m.lastHeard) silent := f.now.Sub(m.lastHeard)
if silent <= bound { if !m.silent(f.now) {
continue continue
} }
severity := conditions.Warning severity := conditions.Warning
@@ -357,7 +357,7 @@ func watchReports(f *signalFacts) []conditions.Observation {
if m.sentAt.IsZero() || m.reportedCurrent || m.asleep() { if m.sentAt.IsZero() || m.reportedCurrent || m.asleep() {
continue continue
} }
if !m.lastHeard.IsZero() && f.now.Sub(m.lastHeard) > heartbeatBound(m.every) { if m.silent(f.now) {
continue // silent: S1 says it, and a silent machine reports nothing continue // silent: S1 says it, and a silent machine reports nothing
} }
bound := max(reportAtLeast, 3*m.lastApply) bound := max(reportAtLeast, 3*m.lastApply)
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"context"
"errors"
"fmt"
"sort"
"time"
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-controller/internal/conditions"
)
// What stopped moving (novox/hq ADR 0433 §2, §3, issue 1156). The watchman writes `stuck.<bucket>.<key>` in its own
// bucket for each key whose pulse expired, and again as moved when the key moves; it never deletes one. The controller
// reads that bucket in two places and holds no timer for either: a probe of the self-check raises each key not moved as
// a condition of the delivery scope, and the check that a machine is silent takes a node's presence key as its verdict.
const (
kindStuck = "stuck"
probeStuckID = "DM"
)
// presenceVerdict is the watchman's word on whether a machine is online, from its stuck presence key.
type presenceVerdict int
const (
// presenceUnsaid: the watchman has written nothing for the machine; the heartbeat rule judges it, as before.
presenceUnsaid presenceVerdict = iota
// presenceHeard: the key says moved, the machine is back online.
presenceHeard
// presenceSilent: the key says not moved, the machine was offline past its bound.
presenceSilent
)
// presenceEntry is presence.<node> as the watchman wrote it: whether the machine is online and when the entry was made.
// The watchman writes it only when it changes, so for an online machine the time is when it connected.
type presenceEntry struct {
Online bool
At time.Time
}
// applyPresence sets what the watchman says of a machine: the verdict of its stuck presence key (nil when there is none).
// A presence entry that says online counts the machine as heard now; otherwise last heard is the later of the store's
// last_seen and the entry's time (the time it went offline).
func applyPresence(m *machineFacts, stuck *sdkdelivery.Stuck, entry *presenceEntry, now time.Time) {
switch {
case stuck == nil:
m.presence = presenceUnsaid
case stuck.Moved:
m.presence = presenceHeard
default:
m.presence = presenceSilent
}
switch {
case entry == nil:
case entry.Online:
m.lastHeard = now
case entry.At.After(m.lastHeard):
m.lastHeard = entry.At
}
}
// silent says the machine is silent: the watchman's verdict where it gave one, else the heartbeat rule. A machine never
// heard from is not silent, it has not joined.
func (m machineFacts) silent(now time.Time) bool {
switch m.presence {
case presenceSilent:
return true
case presenceHeard:
return false
}
return !m.lastHeard.IsZero() && now.Sub(m.lastHeard) > heartbeatBound(m.every)
}
// stuckObservations are the conditions of what stopped moving: one for each key not moved, in the delivery scope, kind
// stuck, naming the key and what it waited for. A node's presence key is left to the silent check; a moved key raises
// nothing, so its condition clears.
func stuckObservations(entries map[string]sdkdelivery.Stuck) []conditions.Observation {
keys := make([]string, 0, len(entries))
for k := range entries {
keys = append(keys, k)
}
sort.Strings(keys)
var out []conditions.Observation
for _, k := range keys {
s := entries[k]
if s.Moved {
continue
}
if s.Bucket == sdkdelivery.PresenceBucket {
if node, _, _, err := sdkdelivery.ParsePresenceKey(s.Key); err == nil && node != "" {
continue
}
}
waited := s.Waited
if waited == "" {
waited = "a move"
}
out = append(out, conditions.Observation{Scope: conditions.ScopeDelivery, ID: k, Kind: kindStuck,
Severity: conditions.Warning,
Summary: fmt.Sprintf("%s has not moved since %s: it waited for %s", k, s.Since.UTC().Format("2006-01-02 15:04 MST"),
waited),
Said: fmt.Sprintf("%s: waiting for %s since %s", k, waited, s.Since.UTC().Format(time.RFC3339))})
}
return out
}
// readStuck is every key of the stuck bucket and its value; none when the bucket is not there yet (the watchman not
// delivered). A value of another version is left out, said.
func readStuck(ctx context.Context, conn *nats.Conn) (map[string]sdkdelivery.Stuck, error) {
api, err := jetstream.New(conn)
if err != nil {
return nil, err
}
kv, err := api.KeyValue(ctx, sdkdelivery.StuckBucket)
if errors.Is(err, jetstream.ErrBucketNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("the watchman's stuck bucket: %w", err)
}
keys, err := boundedCall(ctx, "listing the stuck keys", func(ctx context.Context) ([]string, error) { return kv.Keys(ctx) })
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
if err != nil {
return nil, err
}
out := map[string]sdkdelivery.Stuck{}
for _, key := range keys {
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
continue
}
if err != nil {
return nil, err
}
s, err := sdkdelivery.DecodeStuck(e.Value())
if err != nil {
fmt.Printf("the stuck key %s is not read: %v\n", key, err)
continue
}
out[key] = s
}
return out, nil
}
// readPresence is, by node, presence.<node>'s entry; a node without an entry is left out.
func readPresence(ctx context.Context, conn *nats.Conn, nodes []string) (map[string]presenceEntry, error) {
api, err := jetstream.New(conn)
if err != nil {
return nil, err
}
kv, err := api.KeyValue(ctx, sdkdelivery.PresenceBucket)
if errors.Is(err, jetstream.ErrBucketNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("the watchman's presence bucket: %w", err)
}
out := map[string]presenceEntry{}
for _, node := range nodes {
key, err := sdkdelivery.PresenceNodeKey(node)
if err != nil {
continue
}
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
continue
}
if err != nil {
return nil, err
}
p, err := sdkdelivery.DecodePresence(e.Value())
if err != nil {
fmt.Printf("the presence key %s is not read: %v\n", key, err)
continue
}
out[node] = presenceEntry{Online: p.Online, At: e.Created()}
}
return out, nil
}
// withPresence says of each machine what the watchman says: its stuck presence key and its presence entry's time.
func withPresence(ctx context.Context, conn *nats.Conn, machines []machineFacts, now time.Time) error {
stuck, err := readStuck(ctx, conn)
if err != nil {
return err
}
names := make([]string, len(machines))
for i, m := range machines {
names[i] = m.name
}
entries, err := readPresence(ctx, conn, names)
if err != nil {
return err
}
for i := range machines {
var said *sdkdelivery.Stuck
if key, err := sdkdelivery.StuckKey(sdkdelivery.PresenceBucket, machines[i].name); err == nil {
if s, ok := stuck[key]; ok {
said = &s
}
}
var entry *presenceEntry
if e, ok := entries[machines[i].name]; ok {
entry = &e
}
applyPresence(&machines[i], said, entry, now)
}
return nil
}
// probeStuck is DM: every key the watchman says stopped moving is raised, but a node's presence, which the silent check
// takes.
func probeStuck(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
if d.js == nil {
return nil, nil
}
entries, err := readStuck(ctx, d.js.Conn())
if err != nil {
return nil, err
}
return stuckObservations(entries), nil
}
@@ -0,0 +1,82 @@
//go:build integration
package main
import (
"context"
"testing"
"time"
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/testbus"
)
// 1156/AC4: on the embedded bus with the watchman's buckets, a stuck key written raises a condition and written as moved
// clears it; a node's presence key not moved says the machine silent through the facts, and moved says it heard.
func TestAStuckKeyWrittenAndThenMovedRaisesAndClearsACondition(t *testing.T) { // 1156/AC4
js, err := broker.Dial(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(js.Close)
if _, err := broker.RaiseBuckets(js, []broker.Bucket{
{Module: sdkdelivery.WatchmanModule, Name: "stuck"},
{Module: sdkdelivery.WatchmanModule, Name: "presence", History: sdkdelivery.PresenceHistory},
}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
api, err := jetstream.New(js.Conn())
if err != nil {
t.Fatal(err)
}
kv, err := api.KeyValue(ctx, sdkdelivery.StuckBucket)
if err != nil {
t.Fatal(err)
}
write := func(bucket, key string, moved bool) string {
t.Helper()
stuckKey, err := sdkdelivery.StuckKey(bucket, key)
if err != nil {
t.Fatal(err)
}
body, err := sdkdelivery.EncodeStuck(sdkdelivery.Stuck{Bucket: bucket, Key: key, Waited: "an answer", Since: time.Now().UTC(), Moved: moved})
if err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, stuckKey, body); err != nil {
t.Fatal(err)
}
return stuckKey
}
d := &doctor{js: js}
stuckKey := write(sdkdelivery.PulseBucket, "builds.x", false)
write(sdkdelivery.PresenceBucket, "ace", false)
got, err := probeStuck(ctx, d)
if err != nil || len(got) != 1 || got[0].ID != stuckKey || got[0].Kind != kindStuck {
t.Fatalf("a stuck key written: %+v %v", got, err)
}
machines := []machineFacts{{name: "ace", lastHeard: time.Now()}}
if err := withPresence(ctx, js.Conn(), machines, time.Now()); err != nil {
t.Fatal(err)
}
if !machines[0].silent(time.Now()) {
t.Fatal("a presence key not moved did not say the machine silent")
}
write(sdkdelivery.PulseBucket, "builds.x", true)
write(sdkdelivery.PresenceBucket, "ace", true)
if got, err := probeStuck(ctx, d); err != nil || len(got) != 0 {
t.Fatalf("a stuck key written as moved: %+v %v", got, err)
}
machines = []machineFacts{{name: "ace", lastHeard: time.Now().Add(-time.Hour), every: time.Minute}}
if err := withPresence(ctx, js.Conn(), machines, time.Now()); err != nil {
t.Fatal(err)
}
if machines[0].silent(time.Now()) {
t.Fatal("a presence key that moved still says the machine silent")
}
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"strings"
"testing"
"time"
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/link"
)
func aStuckKey(t *testing.T, bucket, key string) string {
t.Helper()
k, err := sdkdelivery.StuckKey(bucket, key)
if err != nil {
t.Fatal(err)
}
return k
}
// 1156/AC1: each not-moved stuck key that is not a node's presence is an observation of the delivery scope, kind stuck,
// naming the key and what it waited for; a moved key raises nothing, so its condition clears.
func TestEachStuckKeyThatHasNotMovedIsAConditionOfTheDeliveryScope(t *testing.T) { // 1156/AC1
since := time.Date(2026, 10, 18, 19, 0, 0, 0, time.UTC)
pulse := aStuckKey(t, sdkdelivery.PulseBucket, "x")
moved := aStuckKey(t, sdkdelivery.HaveBucket, "ace.zsh")
node := aStuckKey(t, sdkdelivery.PresenceBucket, "ace")
part := aStuckKey(t, sdkdelivery.PresenceBucket, "part.mesh-controller.one")
entries := map[string]sdkdelivery.Stuck{
pulse: {Bucket: sdkdelivery.PulseBucket, Key: "x", Waited: "the build of zsh to be answered", Since: since},
moved: {Bucket: sdkdelivery.HaveBucket, Key: "ace.zsh", Waited: "a want", Since: since, Moved: true},
node: {Bucket: sdkdelivery.PresenceBucket, Key: "ace", Waited: "its return", Since: since},
part: {Bucket: sdkdelivery.PresenceBucket, Key: "part.mesh-controller.one", Waited: "its return", Since: since},
}
got := stuckObservations(entries)
if len(got) != 2 {
t.Fatalf("observations %+v, want the pulse key and the part's presence", got)
}
byID := map[string]conditions.Observation{}
for _, o := range got {
if o.Scope != conditions.ScopeDelivery || o.Kind != kindStuck {
t.Errorf("%+v is not a stuck condition of the delivery scope", o)
}
byID[o.ID] = o
}
o, ok := byID[pulse]
if !ok || !strings.Contains(o.Summary, pulse) || !strings.Contains(o.Summary, "the build of zsh to be answered") {
t.Fatalf("the condition does not name the key and what it waited for: %+v", got)
}
if _, ok := byID[part]; !ok {
t.Errorf("a part's presence is left out: %v", byID)
}
// Moved again: the same key, written as moved, raises nothing.
entries[pulse] = sdkdelivery.Stuck{Bucket: sdkdelivery.PulseBucket, Key: "x", Since: since, Moved: true}
if got := stuckObservations(entries); len(got) != 1 {
t.Fatalf("a moved key still raises: %+v", got)
}
}
func aMachineHeardAt(now time.Time, ago time.Duration) machineFacts {
return machineFacts{name: "laptop", lastHeard: now.Add(-ago), every: time.Minute}
}
// 1156/AC2: a node's stuck presence key not moved makes the machine silent, unless it sleeps; moved, it is heard; no
// timer is armed (the verdict is the key's, whatever the time of the last word).
func TestAStuckPresenceKeyIsTheSilentVerdict(t *testing.T) { // 1156/AC2
now := time.Now()
f := calm(now)
f.machines = append(f.machines, aMachineHeardAt(now, 10*time.Second))
notMoved := sdkdelivery.Stuck{Bucket: sdkdelivery.PresenceBucket, Key: "laptop", Waited: "its return", Since: now.Add(-3 * time.Minute)}
applyPresence(&f.machines[1], &notMoved, nil, now)
got := watchHeartbeats(f)
if len(got) != 1 || got[0].Machine != "laptop" || got[0].Kind != "silent" {
t.Fatalf("a presence key not moved: %+v", got)
}
// Asleep: not silent.
f.machines[1].power = link.PowerState{State: "sleeping", At: now.Add(-time.Hour)}
if got := watchHeartbeats(f); len(got) != 0 {
t.Fatalf("a sleeping machine raised %+v", got)
}
// Moved: heard, whatever the store's last word says.
f.machines[1].power = link.PowerState{}
f.machines[1].lastHeard = now.Add(-2 * time.Hour)
moved := notMoved
moved.Moved = true
applyPresence(&f.machines[1], &moved, nil, now)
if got := watchHeartbeats(f); len(got) != 0 {
t.Fatalf("a presence key that moved raised %+v", got)
}
}
// 1156/AC3: with a presence entry that says offline, last heard is the later of last_seen and the entry's time; with none,
// last_seen. An entry that says online counts as heard now: the watchman writes it only when it changes, so its time is
// when the machine connected, and a machine online for hours with no stuck key is not silent, whatever last_seen says.
func TestLastHeardFollowsThePresenceEntry(t *testing.T) { // 1156/AC3
now := time.Now()
seen := now.Add(-time.Hour)
m := machineFacts{name: "laptop", lastHeard: seen, every: time.Minute}
applyPresence(&m, nil, nil, now)
if !m.lastHeard.Equal(seen) {
t.Fatalf("with no presence entry last heard is %v, want last_seen %v", m.lastHeard, seen)
}
offlineAt := now.Add(-5 * time.Minute)
applyPresence(&m, nil, &presenceEntry{Online: false, At: offlineAt}, now)
if !m.lastHeard.Equal(offlineAt) {
t.Fatalf("offline since %v: last heard is %v", offlineAt, m.lastHeard)
}
m = machineFacts{name: "laptop", lastHeard: now.Add(-time.Minute), every: time.Minute}
applyPresence(&m, nil, &presenceEntry{Online: false, At: now.Add(-time.Hour)}, now)
if !m.lastHeard.Equal(now.Add(-time.Minute)) {
t.Fatalf("an older entry replaced a newer last_seen: %v", m.lastHeard)
}
for _, lastSeen := range []time.Duration{10 * time.Second, 5 * time.Hour} {
m := machineFacts{name: "laptop", lastHeard: now.Add(-lastSeen), every: time.Minute}
applyPresence(&m, nil, &presenceEntry{Online: true, At: now.Add(-6 * time.Hour)}, now)
if m.silent(now) {
t.Fatalf("a machine online since hours ago (last_seen %v ago) with no stuck key is silent", lastSeen)
}
}
}
+16 -1
View File
@@ -155,6 +155,8 @@ type machineFacts struct {
tools bool tools bool
toolsHeard time.Time toolsHeard time.Time
toolsEvery time.Duration toolsEvery time.Duration
// presence is the watchman's word on whether it is online (novox/hq ADR 0433 §3); unsaid, the heartbeat rule judges.
presence presenceVerdict
} }
// asleep says the machine said it would be away and has not said it is back (ADR 0211). // asleep says the machine said it would be away and has not said it is back (ADR 0211).
@@ -470,7 +472,7 @@ func (w *watchdogs) gatherMachines(ctx context.Context, inv *inventory.Inventory
} }
m.reportedCurrent = r.Current m.reportedCurrent = r.Current
} }
if !m.lastHeard.IsZero() && now.Sub(m.lastHeard) > heartbeatBound(m.every) { if m.silent(now) {
anyPast = true anyPast = true
} }
if m.tools && now.Sub(later(m.toolsHeard, link.ToolsBeats.Started())) > heartbeatBound(m.toolsEvery) { if m.tools && now.Sub(later(m.toolsHeard, link.ToolsBeats.Started())) > heartbeatBound(m.toolsEvery) {
@@ -478,6 +480,19 @@ func (w *watchdogs) gatherMachines(ctx context.Context, inv *inventory.Inventory
} }
out = append(out, m) out = append(out, m)
} }
// What the watchman says of each machine: its stuck presence key is the silent verdict, and presence.<node>'s time when
// it was last heard (ADR 0433 §3). Unreadable is said, and the heartbeat rule judges as before.
if w.js != nil {
if err := withPresence(ctx, w.js.Conn(), out, now); err != nil {
fmt.Printf("what the watchman says of the machines' presence cannot be read: %v\n", err)
} else {
for _, m := range out {
if m.silent(now) {
anyPast = true
}
}
}
}
// What a machine past its bound last said of its power, read only then: a machine that said it // What a machine past its bound last said of its power, read only then: a machine that said it
// is asleep is not lost (ADR 0211). Unreadable is said: a sleeping laptop is then called silent, // is asleep is not lost (ADR 0211). Unreadable is said: a sleeping laptop is then called silent,
// which is the louder mistake and the right one to make. // which is the louder mistake and the right one to make.
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,7 +1,7 @@
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4= git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4=
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE= git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83 h1:BrHyE3ifFMUQLIza6I/iNlEgdcysB9/Y+1FO6Q9xws8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4 h1:yYDxFRJoH59kiCoEulD+FBwctyf9CrtKIxlEAsVL39Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -0,0 +1,60 @@
package broker
import (
"slices"
"testing"
)
// 1155/AC1: a module whose manifest reads mesh-controller.checks-running may watch and get every key of the bucket and
// write none.
func TestAModuleReadingChecksRunningReadsAndWritesNone(t *testing.T) { // 1155/AC1
if !slices.Contains(ControllerMirrors(), "checks-running") || BucketName(ControllerSeat, "checks-running") != CheckRunningBucket {
t.Fatalf("a module cannot name %s in `reads`: %v", CheckRunningBucket, ControllerMirrors())
}
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-watchman", PasswordHash: "x",
Reads: []string{"mesh-controller.checks-running"}})
if err != nil {
t.Fatal(err)
}
stream := "KV_" + CheckRunningBucket
has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream)
has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>")
has(t, perms.Publish, "$JS.API.CONSUMER.CREATE."+stream+".>")
for _, p := range perms.Publish {
if SubjectsOverlap(p, "$KV."+CheckRunningBucket+".any.key") {
t.Errorf("the reading module may write %s through %q", CheckRunningBucket, p)
}
}
}
// 1155/AC3: the writers table names checks-running's writer as the build seat's holders, and the rule that a mirror is
// written by the controller alone names it as the exception, with its reason.
func TestChecksRunningIsAMirrorWrittenByTheBuildSeatsHolders(t *testing.T) { // 1155/AC3
why := MirrorsWrittenByAnother["checks-running"]
if why == "" {
t.Fatal("the mirrors written by another do not name checks-running with a reason")
}
subject := "$KV." + CheckRunningBucket + ".novox.a.7"
var rows int
for _, row := range WritersTable {
for _, s := range row.Subjects {
if s != "$KV."+CheckRunningBucket+".>" {
continue
}
rows++
holder := Principal{Kind: KindModule, Module: "builder", Node: "one", Holds: []Seat{{Name: TheBuildSeat}}}
other := Principal{Kind: KindModule, Module: "mesh-delivery-watchman", Node: "one"}
if row.Writes == nil || !row.Writes(holder, subject) || row.Writes(other, subject) {
t.Errorf("%q is not written by the build seat's holders alone", row.State)
}
}
}
if rows != 1 {
t.Fatalf("%d rows of the writers table write %s, want one", rows, CheckRunningBucket)
}
for m := range MirrorsWrittenByAnother {
if m != "checks-running" {
t.Errorf("the mirror %q is written by another and is not checks-running", m)
}
}
}
+15 -2
View File
@@ -56,7 +56,7 @@ var (
// check ends; a key not renewed within the bucket's age is a builder that died, and is gone. A builder that takes a // check ends; a key not renewed within the bucket's age is a builder that died, and is gone. A builder that takes a
// waiting request for a head and trunk commit a key names drops it. **Written by the build seat's holders, several by // waiting request for a head and trunk commit a key names drops it. **Written by the build seat's holders, several by
// design** — each the key of the check it runs. // design** — each the key of the check it runs.
CheckRunningBucket = BucketName(ControllerSeat, "checks-running") CheckRunningBucket = BucketName(ControllerSeat, ChecksRunning)
// DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged // DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged
// on (novox/hq ADR 0420 §7, issue 1025): per merge a key `<owner>/<repo>/<number>/<head sha>` holding the // on (novox/hq ADR 0420 §7, issue 1025): per merge a key `<owner>/<repo>/<number>/<head sha>` holding the
// announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new // announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new
@@ -133,9 +133,22 @@ func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, Ass
// ControllerMirrors are the local names of the controller's buckets a module may read: the record mirrors and the // ControllerMirrors are the local names of the controller's buckets a module may read: the record mirrors and the
// chain's buckets. // chain's buckets.
func ControllerMirrors() []string { func ControllerMirrors() []string {
return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts} return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, ChecksRunning}
} }
// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule
// that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no
// other.
var MirrorsWrittenByAnother = map[string]string{
ChecksRunning: "the checks running are said by the builders themselves: every machine holding the build seat puts the " +
"key of the check it runs, at its start and at each heartbeat, and deletes it when the check ends, so the " +
"controller asserts the bucket and writes none of its keys (ADR 0406 §3); it is readable through `reads` so " +
"the watchman sees a check that stopped (ADR 0433 §6)",
}
// ChecksRunning is the local name of the bucket of the checks running, as a reader names it in `reads`.
const ChecksRunning = "checks-running"
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket. // The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
const ( const (
MirrorValueBytes = 64 << 10 MirrorValueBytes = 64 << 10
@@ -135,6 +135,12 @@ func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) {
for _, m := range ControllerMirrors() { for _, m := range ControllerMirrors() {
bucket := BucketName(ControllerSeat, m) bucket := BucketName(ControllerSeat, m)
listed[bucket] = true listed[bucket] = true
if why, other := MirrorsWrittenByAnother[m]; other { // 1155/AC3: the exception, with its reason
if why == "" {
t.Errorf("the mirror %s is written by another and says no reason", bucket)
}
continue
}
if !rowed[bucket] { if !rowed[bucket] {
t.Errorf("the mirror %s is listed and no row of the writers table writes it", bucket) t.Errorf("the mirror %s is listed and no row of the writers table writes it", bucket)
} }
+33 -13
View File
@@ -436,30 +436,50 @@ func retentionOf(r Retention) nats.RetentionPolicy {
// discards what the bucket holds, and what a module's state holds is data. The mesh's caps are // discards what the bucket holds, and what a module's state holds is data. The mesh's caps are
// asserted with the owner's options, so a bucket made by hand converges to them. // asserted with the owner's options, so a bucket made by hand converges to them.
func (j *JetStream) EnsureBucket(b Bucket) error { func (j *JetStream) EnsureBucket(b Bucket) error {
history := b.History
if history == 0 {
history = 1
}
js, err := jetstream.New(j.conn) js, err := jetstream.New(j.conn)
if err != nil { if err != nil {
return err return err
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ // A stream that allows a time to live per message cannot be made to disallow it (the server refuses the update),
Bucket: b.Bucket(), // so a bucket that has it keeps it whatever the owner now declares (novox/hq ADR 0433 §1).
Description: b.Why(), var held time.Duration
History: uint8(history), if kv, err := js.KeyValue(ctx, b.Bucket()); err == nil {
TTL: time.Duration(b.TTLSeconds) * time.Second, if st, err := kv.Status(ctx); err == nil {
MaxValueSize: StateMaxValueBytes, held = st.LimitMarkerTTL()
MaxBytes: StateMaxBytes, }
Storage: jetstream.FileStorage, }
}); err != nil { if _, err := js.CreateOrUpdateKeyValue(ctx, BucketConfig(b, held)); err != nil {
return fmt.Errorf("asserting bucket %s: %w", b.Bucket(), err) return fmt.Errorf("asserting bucket %s: %w", b.Bucket(), err)
} }
return nil return nil
} }
// BucketConfig is the options a module's bucket is asserted with. held is the marker age the bucket already has, zero
// when it is absent or has none: **a bucket that allows a time to live per message keeps it** (the server refuses to
// turn it off), so without key-ttl it is asserted with the age it holds, and with key-ttl with KeyTTLMarkerTTL.
func BucketConfig(b Bucket, held time.Duration) jetstream.KeyValueConfig {
history := b.History
if history == 0 {
history = 1
}
markers := held
if b.KeyTTL {
markers = KeyTTLMarkerTTL
}
return jetstream.KeyValueConfig{
Bucket: b.Bucket(),
Description: b.Why(),
History: uint8(history),
TTL: time.Duration(b.TTLSeconds) * time.Second,
MaxValueSize: StateMaxValueBytes,
MaxBytes: StateMaxBytes,
Storage: jetstream.FileStorage,
LimitMarkerTTL: markers,
}
}
// BucketNames is every key-value bucket on the server, the mesh's and anybody else's. // BucketNames is every key-value bucket on the server, the mesh's and anybody else's.
func (j *JetStream) BucketNames() ([]string, error) { func (j *JetStream) BucketNames() ([]string, error) {
js, err := jetstream.New(j.conn) js, err := jetstream.New(j.conn)
+7
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"time"
) )
// A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3). // A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3).
@@ -35,8 +36,14 @@ type Bucket struct {
// PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR // PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR
// 0260), and is granted that key and no other. // 0260), and is granted that key and no other.
PerMachine bool PerMachine bool
// KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its
// own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends.
KeyTTL bool
} }
// KeyTTLMarkerTTL is how long the marker an expired key leaves is kept: long enough for a watcher to hear it.
const KeyTTLMarkerTTL = time.Minute
// BucketName is the bucket a module's state lives in: the module and the local name joined by an // BucketName is the bucket a module's state lives in: the module and the local name joined by an
// underscore, which neither may contain, so two modules can never derive one bucket. // underscore, which neither may contain, so two modules can never derive one bucket.
func BucketName(module, name string) string { return module + "_" + name } func BucketName(module, name string) string { return module + "_" + name }
+50
View File
@@ -3,9 +3,14 @@
package broker package broker
import ( import (
"context"
"errors"
"time"
"testing" "testing"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
) )
func TestABucketIsAssertedInPlace(t *testing.T) { func TestABucketIsAssertedInPlace(t *testing.T) {
@@ -53,3 +58,48 @@ func TestABucketIsAssertedOverAHeldConnection(t *testing.T) {
t.Fatalf("the bucket is not there: %v", err) t.Fatalf("the bucket is not there: %v", err)
} }
} }
// 1154/AC4: on the pinned server a bucket declared with key-ttl takes a put with a time to live of a second, the key is
// gone after it and the server has written a delete marker; and the bucket asserted again without key-ttl is not refused.
func TestAKeyTTLBucketExpiresAKeyAndIsAssertedAgain(t *testing.T) { // 1154/AC4
js := aLiveBus(t)
b := Bucket{Module: "statetest", Name: "pulse", KeyTTL: true}
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
t.Fatalf("a real server refused a key-ttl bucket: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
kv, err := jetstream.New(js.Conn())
if err != nil {
t.Fatal(err)
}
bucket, err := kv.KeyValue(ctx, b.Bucket())
if err != nil {
t.Fatal(err)
}
if _, err := bucket.Create(ctx, "presence.alpha", []byte("x"), jetstream.KeyTTL(time.Second)); err != nil {
t.Fatalf("the bucket refused a key with a time to live: %v", err)
}
deadline := time.Now().Add(10 * time.Second)
for {
if _, err := bucket.Get(ctx, "presence.alpha"); errors.Is(err, jetstream.ErrKeyNotFound) {
break
}
if time.Now().After(deadline) {
t.Fatal("the key is still there after its time to live")
}
time.Sleep(200 * time.Millisecond)
}
hist, err := bucket.History(ctx, "presence.alpha")
if err != nil {
t.Fatal(err)
}
if last := hist[len(hist)-1]; last.Operation() == jetstream.KeyValuePut {
t.Fatalf("no delete marker was left: last entry is %v", last.Operation())
}
// The owner no longer says key-ttl: the server cannot turn the age off, so the bucket keeps it and is not refused.
b.KeyTTL = false
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
t.Fatalf("asserting the bucket without key-ttl was refused, so a restart would fail: %v", err)
}
}
+31
View File
@@ -0,0 +1,31 @@
package broker
import (
"testing"
"time"
)
// 1154/AC2: a bucket declared with key-ttl is asserted with the marker age that allows a time to live per message;
// one without is asserted with none.
func TestAKeyTTLBucketIsAssertedWithTheMarkerAge(t *testing.T) { // 1154/AC2
with := BucketConfig(Bucket{Module: "mesh-delivery-watchman", Name: "pulse", KeyTTL: true}, 0)
if with.LimitMarkerTTL != KeyTTLMarkerTTL || KeyTTLMarkerTTL <= 0 {
t.Fatalf("a key-ttl bucket is asserted with marker age %v, want %v", with.LimitMarkerTTL, KeyTTLMarkerTTL)
}
without := BucketConfig(Bucket{Module: "mesh-delivery-watchman", Name: "stuck"}, 0)
if without.LimitMarkerTTL != 0 {
t.Fatalf("a bucket without key-ttl is asserted with marker age %v, want none", without.LimitMarkerTTL)
}
if with.Bucket != "mesh-delivery-watchman_pulse" || with.History != 1 || with.MaxBytes != StateMaxBytes {
t.Fatalf("the rest of the options changed: %+v", with)
}
}
// 1154/AC3: a bucket that already allows a time to live per message keeps it when its owner no longer says key-ttl,
// because the server refuses to turn it off; it is not asserted without it.
func TestABucketThatAllowsAKeyTTLKeepsIt(t *testing.T) { // 1154/AC3
got := BucketConfig(Bucket{Module: "m", Name: "s"}, 5*time.Minute)
if got.LimitMarkerTTL != 5*time.Minute {
t.Fatalf("a bucket that held marker age 5m is asserted with %v, which the server refuses", got.LimitMarkerTTL)
}
}
+6 -3
View File
@@ -36,6 +36,9 @@ type StateDeclaration struct {
// PerMachine says the state is keyed by machine: each machine's instance writes the key named for // PerMachine says the state is keyed by machine: each machine's instance writes the key named for
// its machine and no other, and the bus grants it that key alone (novox/hq ADR 0260). // its machine and no other, and the bus grants it that key alone (novox/hq ADR 0260).
PerMachine bool `json:"per-machine,omitempty"` PerMachine bool `json:"per-machine,omitempty"`
// KeyTTL says each key of the bucket lives the time its writer gives it, and the server writes a delete marker
// when it ends (novox/hq ADR 0433 §1): the bucket is made with the server's time to live per message.
KeyTTL bool `json:"key-ttl,omitempty"`
} }
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}. // UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
@@ -49,7 +52,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
dec := json.NewDecoder(bytes.NewReader(trimmed)) dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil { if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine}: %w", typedUnknown(err)) return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: %w", typedUnknown(err))
} }
*s = StateDeclaration(full) *s = StateDeclaration(full)
return nil return nil
@@ -57,7 +60,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
// MarshalJSON writes back the short form when there is nothing else to say. // MarshalJSON writes back the short form when there is nothing else to say.
func (s StateDeclaration) MarshalJSON() ([]byte, error) { func (s StateDeclaration) MarshalJSON() ([]byte, error) {
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine { if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL {
return json.Marshal(s.Name) return json.Marshal(s.Name)
} }
type plain StateDeclaration type plain StateDeclaration
@@ -130,7 +133,7 @@ func StateProblems(m Manifest) []string {
// controller's account alone (the writers table); declared as a module's state, they would be asserted a // controller's account alone (the writers table); declared as a module's state, they would be asserted a
// second way and granted to the module's own principal as well. Named here so a read of one is a read of // second way and granted to the module's own principal as well. Named here so a read of one is a read of
// kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing. // kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing.
var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts"} var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts", "checks-running"}
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no // StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
// such state. An absent owner says nothing — a module may be installed long before the one whose // such state. An absent owner says nothing — a module may be installed long before the one whose
+17 -1
View File
@@ -39,7 +39,7 @@ func TestAStateNameIsLocalAndOneToken(t *testing.T) {
{`{"module":"a.b","version":"1","state":["s"]}`, `no dot`}, {`{"module":"a.b","version":"1","state":["s"]}`, `no dot`},
{`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`}, {`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`},
{`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`}, {`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`},
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine}`}, {`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine, key-ttl}`},
} { } {
_, err := ParseManifest([]byte(c.manifest)) _, err := ParseManifest([]byte(c.manifest))
if err == nil { if err == nil {
@@ -107,3 +107,19 @@ func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) {
t.Fatalf("the catalogue pass said %v", problems) t.Fatalf("the catalogue pass said %v", problems)
} }
} }
// 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before.
func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` +
`"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`))
if err != nil {
t.Fatal(err)
}
if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL {
t.Fatalf("key-ttl not read as said: %+v", m.State)
}
out, _ := json.Marshal(m.State)
if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]` {
t.Fatalf("written back as %s", out)
}
}
+1 -1
View File
@@ -269,7 +269,7 @@ func bucketsOf(m catalogue.Manifest) []broker.Bucket {
var out []broker.Bucket var out []broker.Bucket
for _, s := range m.State { for _, s := range m.State {
out = append(out, broker.Bucket{Module: m.Module, Name: s.Name, History: s.History, TTLSeconds: s.TTLSeconds, out = append(out, broker.Bucket{Module: m.Module, Name: s.Name, History: s.History, TTLSeconds: s.TTLSeconds,
PerMachine: s.PerMachine}) PerMachine: s.PerMachine, KeyTTL: s.KeyTTL})
} }
return out return out
} }
+16
View File
@@ -0,0 +1,16 @@
package inventory
import (
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// 1154/AC2: a module's state with key-ttl is composed into a bucket that carries KeyTTL; without it, none.
func TestAKeyTTLStateIsComposedIntoABucketThatSaysSo(t *testing.T) { // 1154/AC2
got := bucketsOf(catalogue.Manifest{Module: "mesh-delivery-watchman",
State: []catalogue.StateDeclaration{{Name: "pulse", KeyTTL: true}, {Name: "stuck"}}})
if len(got) != 2 || !got[0].KeyTTL || got[1].KeyTTL {
t.Fatalf("buckets composed as %+v", got)
}
}
+16 -1
View File
@@ -180,6 +180,9 @@ type CheckRunning struct {
Trunk string `json:"trunk"` Trunk string `json:"trunk"`
Builder string `json:"builder"` Builder string `json:"builder"`
Build string `json:"build"` Build string `json:"build"`
// Started is when the check started, kept on every heartbeat: the entry's own time is the last heartbeat's, and a
// reader that asks how long a check has run reads this (novox/hq ADR 0433 §6).
Started time.Time `json:"started"`
} }
// RunningKeyOf is the key a check is said running under: its pull request's tokens, or `group.<name>` for a group's // RunningKeyOf is the key a check is said running under: its pull request's tokens, or `group.<name>` for a group's
@@ -253,11 +256,23 @@ func keepRunning(ctx context.Context, conn *nats.Conn, r BuildRequest, builder s
fmt.Fprintf(os.Stderr, "%s runs without saying so: %v\n", r.ID, err) fmt.Fprintf(os.Stderr, "%s runs without saying so: %v\n", r.ID, err)
return func() {} return func() {}
} }
return keepRunningIn(ctx, kv, key, r, builder, every, time.Now())
}
// runningStore is what keepRunning needs of the bucket: a put and a delete.
type runningStore interface {
Put(ctx context.Context, key string, value []byte) (uint64, error)
Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error
}
// keepRunningIn is keepRunning on a bucket in hand, the check having started at started: the value is made once, so every
// heartbeat puts the same started.
func keepRunningIn(ctx context.Context, kv runningStore, key string, r BuildRequest, builder string, every time.Duration, started time.Time) (stop func()) {
trunk := "" trunk := ""
if r.Check != nil { if r.Check != nil {
trunk = r.Check.Trunk trunk = r.Check.Trunk
} }
body, _ := json.Marshal(CheckRunning{Head: r.Ref, Trunk: trunk, Builder: builder, Build: r.ID}) body, _ := json.Marshal(CheckRunning{Head: r.Ref, Trunk: trunk, Builder: builder, Build: r.ID, Started: started.UTC()})
put := func() { put := func() {
putting, end := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) putting, end := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer end() defer end()
@@ -0,0 +1,61 @@
package link
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
type recordedRunning struct {
mu sync.Mutex
bodies [][]byte
}
func (r *recordedRunning) Put(_ context.Context, _ string, value []byte) (uint64, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.bodies = append(r.bodies, value)
return uint64(len(r.bodies)), nil
}
func (r *recordedRunning) Delete(context.Context, string, ...jetstream.KVDeleteOpt) error { return nil }
func (r *recordedRunning) puts() [][]byte {
r.mu.Lock()
defer r.mu.Unlock()
return append([][]byte(nil), r.bodies...)
}
// 1155/AC2: a check starting puts its key, and puts it again at each heartbeat, and every value carries the same Started,
// the time the check started.
func TestACheckRunningKeepsItsStartAcrossHeartbeats(t *testing.T) { // 1155/AC2
store := &recordedRunning{}
started := time.Date(2026, 10, 18, 19, 0, 0, 0, time.UTC)
stop := keepRunningIn(context.Background(), store, "novox.a.7", aPullCheck("b1", 7, "head-7-000000000", "trunk-1-000000", false),
"builder-1", 5*time.Millisecond, started)
deadline := time.Now().Add(5 * time.Second)
for len(store.puts()) < 3 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
stop()
puts := store.puts()
if len(puts) < 3 {
t.Fatalf("the key was put %d times, want the first and two heartbeats", len(puts))
}
for i, body := range puts {
var got CheckRunning
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if !got.Started.Equal(started) {
t.Fatalf("put %d carries Started %v, want %v", i, got.Started, started)
}
if got.Builder != "builder-1" || got.Build != "b1" {
t.Fatalf("put %d lost the rest of the value: %+v", i, got)
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
mesh-sdk 2b420603fe835d6b80e2c98080ab3094c6896c50 conformance/events/module-event.json mesh-sdk 238440d6f8c46a8dd1d829f4313d7b035a6ca847 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it: To move them, from this repository's root, with the two repositories checked out beside it:
+2 -2
View File
@@ -35,8 +35,8 @@ import (
// its own node only, and the watchman writes presence and pulse. // its own node only, and the watchman writes presence and pulse.
const ( const (
HaveBucket = "node-engine_have" HaveBucket = "node-engine_have"
PresenceBucket = "mesh-delivery-watchman_presence" PresenceBucket = WatchmanModule + "_presence"
PulseBucket = "mesh-delivery-watchman_pulse" PulseBucket = WatchmanModule + "_pulse"
) )
// HaveHistory and PresenceHistory are the revisions the have and presence buckets keep per key (research 063 // HaveHistory and PresenceHistory are the revisions the have and presence buckets keep per key (research 063
+65
View File
@@ -0,0 +1,65 @@
package delivery
import (
"encoding/json"
"fmt"
"time"
)
// WatchmanModule is the watchman's module: its buckets are its own state, `<module>_<name>`, and the controller's
// broker grants it alone the bus's connect and disconnect events (novox/hq ADR 0431).
const WatchmanModule = "mesh-delivery-watchman"
// StuckBucket holds what stopped moving (novox/hq ADR 0433 §2): one key per key whose pulse expired, written by the
// watchman, read by the controller's self-check, which raises each as a condition until it says moved. No key of it
// is removed: a key that moves again is written as moved.
const StuckBucket = WatchmanModule + "_stuck"
// StuckKey is the key, in StuckBucket, of the key that stopped moving: `<bucket>.<key>`, formed as PulseKey forms a
// pulse's, so a node or part gone offline is `mesh-delivery-watchman_presence.<presence key>`.
func StuckKey(bucket, key string) (string, error) {
stuck, err := PulseKey(bucket, key)
if err != nil {
return "", fmt.Errorf("delivery: no stuck key: %w", err)
}
return stuck, nil
}
// ParseStuckKey is the inverse of StuckKey, splitting at the first dot.
func ParseStuckKey(stuckKey string) (bucket, key string, err error) {
bucket, key, err = ParsePulseKey(stuckKey)
if err != nil {
return "", "", fmt.Errorf("delivery: %q is not a stuck key: %w", stuckKey, err)
}
return bucket, key, nil
}
// Stuck is the value of a stuck key: the key that stopped moving, what it waited for in words, since when, and
// whether it has moved again since. The watchman writes it; the controller reads it.
type Stuck struct {
V int `json:"v"`
Bucket string `json:"bucket"`
Key string `json:"key"`
Waited string `json:"waited,omitempty"`
Since time.Time `json:"since"`
Moved bool `json:"moved,omitempty"`
}
// EncodeStuck is s as JSON at Version.
func EncodeStuck(s Stuck) ([]byte, error) {
s.V = Version
return json.Marshal(s)
}
// DecodeStuck reads a stuck value; it refuses an unknown version (ErrUnknownVersion).
func DecodeStuck(data []byte) (Stuck, error) {
var s Stuck
err := decode(data, &s)
return s, err
}
// SameStuck says whether two stuck values are the same, so a writer writes only when they differ.
func SameStuck(a, b Stuck) bool {
return a.V == b.V && a.Bucket == b.Bucket && a.Key == b.Key && a.Waited == b.Waited && a.Since.Equal(b.Since) &&
a.Moved == b.Moved
}
+1 -1
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83 # git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4
## explicit; go 1.22 ## explicit; go 1.22
git.novox.be/novox/mesh-sdk/go/asks git.novox.be/novox/mesh-sdk/go/asks
git.novox.be/novox/mesh-sdk/go/delivery git.novox.be/novox/mesh-sdk/go/delivery