Files
mesh-sdk/go/follower.go
T
jschoubben 1dc5d8cb3d Run a watch's handler beside the runtime, and give a refused watch up loudly (issue 774)
The provisioner's watches of gitea.bound, mesh-vault.bound and postgres.bound failed every minute for an hour on
the control-node ("the current values ... did not arrive in 10s") and 1257 changes were refused as "names no
watch of this bundle". Both sides were at fault. The SDK answered a change only once its handler returned, so a
slow handler (Rejudge waits on the lock its composition holds) counted against the runtime's 10 s bound on the
current values; and when the runtime gave up, the SDK forgot the id at once. The runtime (mesh-tools node-tools
internal/bus/state.go StateWatch) stops its watcher on that bound but still drains and delivers the values already
buffered, to an id nobody holds.

Quick fix: a longer runtime bound. Thorough fix here: each state and provision watch has a follower that takes a
change in order and answers at once, its handler on its own goroutine; Watch still returns only once the current
values were handled, and a handler's error is said on standard error. A watch the runtime refuses is said given up
and kept by id, so a change still handed to it is refused saying so. KeptState.Follow asks again with backoff until
taken, the loop four modules wrote for themselves. A log watch keeps its answer after the handler: that is its
back-pressure.
2026-10-30 18:15:51 +01:00

136 lines
4.5 KiB
Go

package stdio
import (
"fmt"
"os"
"sync"
"time"
)
// A watch's handler runs beside the runtime, never in its way (issue 774, the sweep of 2026-10-30).
//
// The runtime hands a watch's current values over one at a time and gives up on the watch when none was handed over
// within its bound (mesh-tools node-tools internal/bus/state.go StateWatch, 10 s). The bundle answered each change only
// once its handler returned, so a slow handler (mesh-provisioner's Rejudge waits on the lock its composition holds)
// counted against the bus's bound: the watch was given up, the SDK forgot its id, and the values the runtime still
// delivered to it were refused as "names no watch of this bundle", 1257 times in an hour on the control-node.
//
// So each watch has a follower: a change is taken into it in the order the runtime hands it over and answered at once,
// and the handler runs on the follower's own goroutine, in that order. Watch still returns only once every current
// value was handled. A handler's error is said on the bundle's standard error, naming the watch and the change, since
// the runtime no longer hears it. A log watch keeps its answer after the handler: its runtime sends the next entry only
// after the answer, which is the back-pressure a replay of a long log needs.
// follower runs one watch's handler on its own goroutine, in order.
type follower[T any] struct {
what string
handle func(T) error
describe func(T) string
mu sync.Mutex
items []followed[T]
wake chan struct{}
}
// followed is one change, or with no change a mark that closes done once everything before it was handled.
type followed[T any] struct {
change T
mark bool
done chan struct{}
}
func newFollower[T any](what string, handle func(T) error, describe func(T) string) *follower[T] {
f := &follower[T]{what: what, handle: handle, describe: describe, wake: make(chan struct{}, 1)}
go f.run()
return f
}
// take queues a change; it never waits on the handler.
func (f *follower[T]) take(change T) {
f.mu.Lock()
f.items = append(f.items, followed[T]{change: change})
f.mu.Unlock()
f.poke()
}
// handled is closed once every change taken before it was handled.
func (f *follower[T]) handled() <-chan struct{} {
done := make(chan struct{})
f.mu.Lock()
f.items = append(f.items, followed[T]{mark: true, done: done})
f.mu.Unlock()
f.poke()
return done
}
func (f *follower[T]) poke() {
select {
case f.wake <- struct{}{}:
default:
}
}
func (f *follower[T]) run() {
for range f.wake {
for {
f.mu.Lock()
if len(f.items) == 0 {
f.mu.Unlock()
break
}
next := f.items[0]
f.items = f.items[1:]
f.mu.Unlock()
if next.mark {
close(next.done)
continue
}
if err := f.handle(next.change); err != nil {
sayf("%s: the handler did not take %s: %v; the next change hands it again", f.what, f.describe(next.change), err)
}
}
}
}
// sayf says something the runtime cannot say for the bundle, on its standard error, which its journal keeps.
func sayf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[mesh-sdk] "+format+"\n", args...)
}
// gaveUpMost bounds what a bundle remembers of the watches it gave up.
const gaveUpMost = 256
// gaveUp keeps why a watch was given up, so a change the runtime still hands to it is refused saying so, never as a
// watch nobody asked for. Called with c.mu held.
func (c *channel) gaveUp(id, why string) {
if c.givenUp == nil || len(c.givenUp) >= gaveUpMost {
c.givenUp = map[string]string{}
}
c.givenUp[id] = why
}
// noWatch is the refusal of a change for watch id, which this bundle never asked or gave up.
func (c *channel) noWatch(kind, id string) string {
c.mu.Lock()
why, given := c.givenUp[id]
c.mu.Unlock()
if given {
sayf("%s: the runtime still hands over changes for the watch %s, which this bundle gave up: %s", kind, id, why)
return fmt.Sprintf("%s: this bundle gave the watch %s up (%s); stop handing it over", kind, id, why)
}
return kind + " names no watch of this bundle"
}
// Follow watches key of the state as Watch does and asks again until the watch is taken, saying each give-up and
// when it asks again (2 s, doubled to a minute): the loop every module wrote for itself, in one place. It returns once
// the watch is taken; call it beside the code that needs it.
func (s KeptState) Follow(key string, handle func(StateChange) error) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := s.Watch(key, handle)
if err == nil {
return
}
sayf("the state %s is not watched yet (%v); asking again in %s", s.name, err, wait)
time.Sleep(wait)
}
}