|
|
|
@@ -14,6 +14,7 @@ import (
|
|
|
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
|
|
|
|
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
|
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -23,8 +24,9 @@ import (
|
|
|
|
|
// 2026-10-25 a want back to an earlier mesh-build-go on g14 applied its capacity of 0, the value from before it was
|
|
|
|
|
// set. Now a write of mesh-config's resolved values for a module on a node, heard from that bucket, composes the node
|
|
|
|
|
// and then every kept fingerprint of that module on that node again, each from its own build's manifest with today's
|
|
|
|
|
// inputs, writing only those whose composition differs (writeComposed). A starting controller does it once for every
|
|
|
|
|
// node, for a change made while no controller heard it.
|
|
|
|
|
// inputs, writing only those whose composition differs (writeComposed). The writes are read from a durable consumer,
|
|
|
|
|
// acknowledged once handled, so a change made while no controller ran is delivered to the next one, and a start
|
|
|
|
|
// composes again only what was written since the last write handled, never every kept composition.
|
|
|
|
|
|
|
|
|
|
// configTouched is the node and module a key of mesh-config_resolved (`<node>.<module>.<key>`) names.
|
|
|
|
|
func configTouched(key string) (node, module string, ok bool) {
|
|
|
|
@@ -92,8 +94,6 @@ func followKept(ctx context.Context, open *stores, node string, modules map[stri
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
unlock := lockComposing(node)
|
|
|
|
|
defer unlock()
|
|
|
|
|
at, err := openComposedPlace(ctx, js, bus.Conn(), node)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
@@ -160,7 +160,11 @@ func followKept(ctx context.Context, open *stores, node string, modules map[stri
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
key, _ := sdkdelivery.ComposedKey(node, k.module, k.fp)
|
|
|
|
|
// One write on the node's keys at a time in this process, held only for the write: composing in memory holds
|
|
|
|
|
// nothing, so a sweep never keeps a take-in from the node (the review of ADR 0545).
|
|
|
|
|
unlock := lockComposing(node)
|
|
|
|
|
w, err := writeComposed(ctx, at, key, c)
|
|
|
|
|
unlock()
|
|
|
|
|
if err != nil {
|
|
|
|
|
errs = append(errs, err)
|
|
|
|
|
continue
|
|
|
|
@@ -175,84 +179,152 @@ func followKept(ctx context.Context, open *stores, node string, modules map[stri
|
|
|
|
|
return wrote, errors.Join(errs...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// configFollowGather is how long the config watch waits after a write for more before it composes: mesh-config writes
|
|
|
|
|
// configFollowGather is how long the config consumer waits after a write for more before it composes: mesh-config writes
|
|
|
|
|
// a module's keys one by one.
|
|
|
|
|
var configFollowGather = time.Second
|
|
|
|
|
|
|
|
|
|
// watchConfigFollow hears every write of mesh-config's resolved values and, for each node and module it touches,
|
|
|
|
|
// composes the node and the module's kept compositions there again, until ctx ends (ADR 0545). A bucket not there is
|
|
|
|
|
// said: a change is then composed by the next act that touches the node and the next start.
|
|
|
|
|
// configFollowConsumer is the controller's durable consumer of mesh-config's resolved values (ADR 0545 §1, §3).
|
|
|
|
|
const configFollowConsumer = broker.ConfigFollowConsumer
|
|
|
|
|
|
|
|
|
|
// configFollowConfig is that consumer: each write from when it is made (a first start composes again nothing it did not
|
|
|
|
|
// hear), acknowledged after the write is handled, so what a stop cut is delivered to the next controller.
|
|
|
|
|
func configFollowConfig() jetstream.ConsumerConfig {
|
|
|
|
|
return jetstream.ConsumerConfig{Durable: configFollowConsumer, FilterSubject: "$KV." + broker.ConfigResolvedBucket + ".>",
|
|
|
|
|
DeliverPolicy: jetstream.DeliverNewPolicy, AckPolicy: jetstream.AckExplicitPolicy, AckWait: takeInAckWait,
|
|
|
|
|
MaxAckPending: takeInPending, Description: "the controller's following of config into kept compositions (novox/hq ADR 0545)"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// kindConfigNotFollowed is the condition of a config consumer that cannot be read: a change of config composes nothing
|
|
|
|
|
// until it is.
|
|
|
|
|
const kindConfigNotFollowed = "config-not-followed"
|
|
|
|
|
|
|
|
|
|
func configNotFollowedObservation(err error) conditions.Observation {
|
|
|
|
|
return conditions.Observation{Scope: conditions.ScopeCore, ID: "config-follow", Token: kindConfigNotFollowed,
|
|
|
|
|
Kind: kindConfigNotFollowed, Severity: conditions.Urgent, Resolver: conditions.ResolverOperator, Source: "config",
|
|
|
|
|
Summary: fmt.Sprintf("mesh-config's resolved values are not heard, so a change of config composes nothing: %v", err)}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// watchConfigFollow reads the writes of mesh-config's resolved values from the controller's durable consumer and, for
|
|
|
|
|
// each node and module they touch, composes the node and the module's kept compositions there again, until ctx ends
|
|
|
|
|
// (ADR 0545). A consumer that cannot be made or read raises config-not-followed and is tried again a minute later; it is
|
|
|
|
|
// cleared once it is read.
|
|
|
|
|
func watchConfigFollow(ctx context.Context, open *stores) {
|
|
|
|
|
bus, err := aBus()
|
|
|
|
|
if err != nil {
|
|
|
|
|
fmt.Printf("config: the resolved values are not heard: %v\n", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
js, err := jetstream.New(bus.Conn())
|
|
|
|
|
if err != nil {
|
|
|
|
|
bus.Close()
|
|
|
|
|
fmt.Printf("config: the resolved values are not heard: %v\n", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
kv, err := js.KeyValue(ctx, broker.ConfigResolvedBucket)
|
|
|
|
|
if err != nil {
|
|
|
|
|
bus.Close()
|
|
|
|
|
fmt.Printf("config: %s is not heard, so a change of config composes nothing until the next act: %v\n",
|
|
|
|
|
broker.ConfigResolvedBucket, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w, err := kv.WatchAll(ctx, jetstream.UpdatesOnly())
|
|
|
|
|
if err != nil {
|
|
|
|
|
bus.Close()
|
|
|
|
|
fmt.Printf("config: %s is not heard: %v\n", broker.ConfigResolvedBucket, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
goServing(ctx, func() {
|
|
|
|
|
defer bus.Close()
|
|
|
|
|
defer func() { _ = w.Stop() }()
|
|
|
|
|
touched := map[string]map[string]bool{}
|
|
|
|
|
var gather <-chan time.Time
|
|
|
|
|
for {
|
|
|
|
|
err := followConfigOnce(ctx, open)
|
|
|
|
|
if ctx.Err() != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
fmt.Printf("config: %v; tried again in a minute\n", err)
|
|
|
|
|
if keeper := conditionsFrom; keeper != nil {
|
|
|
|
|
_, _ = keeper.Observe(ctx, configNotFollowedObservation(err))
|
|
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case e, ok := <-w.Updates():
|
|
|
|
|
if !ok {
|
|
|
|
|
if ctx.Err() == nil {
|
|
|
|
|
fmt.Printf("config: the watch of %s ended; a change is composed by the next start\n", broker.ConfigResolvedBucket)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
case <-time.After(time.Minute):
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// followConfigOnce reads the consumer until it fails or ctx ends.
|
|
|
|
|
func followConfigOnce(ctx context.Context, open *stores) error {
|
|
|
|
|
bus, err := aBus()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer bus.Close()
|
|
|
|
|
js, err := jetstream.New(bus.Conn())
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
c, err := js.CreateOrUpdateConsumer(ctx, "KV_"+broker.ConfigResolvedBucket, configFollowConfig())
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("the consumer of %s: %w", broker.ConfigResolvedBucket, err)
|
|
|
|
|
}
|
|
|
|
|
it, err := c.Messages(jetstream.PullMaxMessages(startTakeIns))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("the consumer of %s: %w", broker.ConfigResolvedBucket, err)
|
|
|
|
|
}
|
|
|
|
|
defer it.Stop()
|
|
|
|
|
if keeper := conditionsFrom; keeper != nil {
|
|
|
|
|
_, _ = keeper.Clear(ctx, configNotFollowedObservation(nil).Key(), "mesh-config's resolved values are heard")
|
|
|
|
|
}
|
|
|
|
|
type next struct {
|
|
|
|
|
m jetstream.Msg
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
got := make(chan next)
|
|
|
|
|
go func() {
|
|
|
|
|
for {
|
|
|
|
|
m, err := it.Next()
|
|
|
|
|
select {
|
|
|
|
|
case got <- next{m, err}:
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
if m != nil {
|
|
|
|
|
_ = m.Nak()
|
|
|
|
|
}
|
|
|
|
|
if e == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
node, module, ok := configTouched(e.Key())
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
var held []jetstream.Msg
|
|
|
|
|
touched := map[string]map[string]bool{}
|
|
|
|
|
var gather <-chan time.Time
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
for _, m := range held {
|
|
|
|
|
_ = m.Nak() // delivered at once to the next controller
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
case n := <-got:
|
|
|
|
|
if n.err != nil {
|
|
|
|
|
for _, m := range held {
|
|
|
|
|
_ = m.Nak()
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("the consumer of %s stopped: %w", broker.ConfigResolvedBucket, n.err)
|
|
|
|
|
}
|
|
|
|
|
held = append(held, n.m)
|
|
|
|
|
key := strings.TrimPrefix(n.m.Subject(), "$KV."+broker.ConfigResolvedBucket+".")
|
|
|
|
|
if node, module, ok := configTouched(key); ok {
|
|
|
|
|
if touched[node] == nil {
|
|
|
|
|
touched[node] = map[string]bool{}
|
|
|
|
|
}
|
|
|
|
|
touched[node][module] = true
|
|
|
|
|
if gather == nil {
|
|
|
|
|
gather = time.After(configFollowGather)
|
|
|
|
|
}
|
|
|
|
|
if gather == nil {
|
|
|
|
|
gather = time.After(configFollowGather)
|
|
|
|
|
}
|
|
|
|
|
case <-gather:
|
|
|
|
|
gather = nil
|
|
|
|
|
now, msgs := touched, held
|
|
|
|
|
touched, held = map[string]map[string]bool{}, nil
|
|
|
|
|
forgetResolved() // the next composition reads the values just written, not a read of the last 5 s
|
|
|
|
|
failed := false
|
|
|
|
|
for node, modules := range now {
|
|
|
|
|
if err := composerFor(open).compose(ctx, []string{node}, false); err != nil {
|
|
|
|
|
fmt.Printf("config: %s is not composed after its config changed: %v\n", node, err)
|
|
|
|
|
failed = true
|
|
|
|
|
}
|
|
|
|
|
case <-gather:
|
|
|
|
|
gather = nil
|
|
|
|
|
now := touched
|
|
|
|
|
touched = map[string]map[string]bool{}
|
|
|
|
|
forgetResolved() // the next composition reads the values just written, not a read of the last 5 s
|
|
|
|
|
for node, modules := range now {
|
|
|
|
|
if err := composerFor(open).compose(ctx, []string{node}, false); err != nil {
|
|
|
|
|
fmt.Printf("config: %s is not composed after its config changed: %v\n", node, err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := followKept(ctx, open, node, modules); err != nil {
|
|
|
|
|
fmt.Printf("config: %s's kept compositions are not composed again: %v\n", node, err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := followKept(ctx, open, node, modules); err != nil {
|
|
|
|
|
fmt.Printf("config: %s's kept compositions are not composed again: %v\n", node, err)
|
|
|
|
|
failed = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, m := range msgs {
|
|
|
|
|
if failed {
|
|
|
|
|
_ = m.NakWithDelay(doneKeyRetry) // tried again: the bus delivers the writes again
|
|
|
|
|
} else {
|
|
|
|
|
_ = m.Ack()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// forgetResolved drops the last read of mesh-config's resolved values.
|
|
|
|
|