Files
mesh-controller/internal/conditions/looks.go
T
jochen dcdafd37f0
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Keep a condition's looks in the store, so a look that changes nothing writes no bucket (issue 420)
Every look rewrote the condition's value in the conditions bucket, because the
value carried the count of looks, the last look and the newest evidence: 131 647
writes in four days for twelve open conditions, against ADR 0289's rule that a
bucket is written only when what it describes changed. The looks now live in the
controller's store (migration 0095, condition_looks), the bucket holds what a
condition says and is written only when that changes, and the conditions verb and
the condition events put the looks back.
2026-10-12 12:11:13 +02:00

164 lines
5.5 KiB
Go

package conditions
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
)
// Looks are what an open condition's observations say between its transitions: how many there were,
// when the newest was, and the newest evidence (to-be 45 §2). They change on every look, so **they are
// not kept in the conditions bucket**, which is written only when what a condition says changes
// (novox/hq ADR 0289 §1 and §4, issue 420): a watcher of the bucket heard thousands of writes a day for a
// dozen conditions that did not change. The controller's store keeps them (LookStore), and the keeper
// puts them back on every condition it reads and every event it says, so `conditions` and the events
// still carry them.
type Looks struct {
Observations int `json:"observations"`
LastObserved time.Time `json:"last-observed"`
Evidence []Evidence `json:"evidence"`
}
// LookStore is where the looks of the open conditions are kept, one row per key.
type LookStore interface {
// KeepLooks writes a condition's looks, replacing what it held.
KeepLooks(ctx context.Context, key string, l Looks) error
// Looked is one condition's looks; false when none are kept.
Looked(ctx context.Context, key string) (Looks, bool, error)
// AllLooks is every condition's looks. An error is an error, never "none kept" (ADR 0227 rule 4).
AllLooks(ctx context.Context) (map[string]Looks, error)
// ForgetLooks removes a condition's looks: it cleared.
ForgetLooks(ctx context.Context, key string) error
}
// lookFields are a condition's fields that are its looks, or say they could not be read: left out of
// the bucket's value.
var lookFields = []string{"observations", "last-observed", "evidence", "looks-unread"}
// looks is the condition's looks.
func (c Condition) looks() Looks {
return Looks{Observations: c.Observations, LastObserved: c.LastObserved, Evidence: c.Evidence}
}
// withLooks puts looks on the condition.
func (c *Condition) withLooks(l Looks) {
c.Observations, c.LastObserved, c.Evidence = l.Observations, l.LastObserved, l.Evidence
}
// BucketValue is a condition as the conditions bucket keeps it: everything but its looks. Its keys are in
// one order, so the same condition is the same bytes and an unchanged one is not written again.
func BucketValue(c Condition) ([]byte, error) {
whole, err := json.Marshal(c)
if err != nil {
return nil, err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(whole, &fields); err != nil {
return nil, err
}
for _, f := range lookFields {
delete(fields, f)
}
return json.Marshal(fields)
}
// attachLooks puts each condition's kept looks on it. A condition the store keeps no looks for keeps
// what its value says (one written before the looks moved out of the bucket says them); one that says
// none is said to have none kept. Looks that cannot be read are said on each condition, never left
// as a count of nothing.
func attachLooks(ctx context.Context, looks LookStore, list []Condition) {
if looks == nil {
return
}
all, err := looks.AllLooks(ctx)
for i := range list {
if err != nil {
list[i].LooksUnread = "its observations could not be read from the controller's store: " + err.Error()
continue
}
l, found := all[list[i].Key]
attachOne(&list[i], l, found)
}
}
func attachOne(c *Condition, l Looks, found bool) {
switch {
case found:
c.withLooks(l)
case c.LastObserved.IsZero():
c.LooksUnread = "the controller's store keeps no observations for it"
}
}
// MemoryLooks keeps looks in this process: for tests, and for a keeper given no store of looks.
type MemoryLooks struct {
mu sync.Mutex
looks map[string]Looks
// Kept counts the writes, so a test can say a look was kept.
kept int
}
// NewMemoryLooks is an empty store of looks.
func NewMemoryLooks() *MemoryLooks { return &MemoryLooks{looks: map[string]Looks{}} }
func (m *MemoryLooks) KeepLooks(_ context.Context, key string, l Looks) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.looks == nil {
m.looks = map[string]Looks{}
}
l.Evidence = append([]Evidence(nil), l.Evidence...)
m.looks[key] = l
m.kept++
return nil
}
func (m *MemoryLooks) Looked(_ context.Context, key string) (Looks, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
l, ok := m.looks[key]
l.Evidence = append([]Evidence(nil), l.Evidence...)
return l, ok, nil
}
func (m *MemoryLooks) AllLooks(context.Context) (map[string]Looks, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make(map[string]Looks, len(m.looks))
for k, l := range m.looks {
l.Evidence = append([]Evidence(nil), l.Evidence...)
out[k] = l
}
return out, nil
}
func (m *MemoryLooks) ForgetLooks(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.looks, key)
return nil
}
// LooksKept is how many times looks were written.
func (m *MemoryLooks) LooksKept() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.kept
}
// NoLooks is a store of looks that cannot be reached, saying why on every read and write: a command
// whose process could not open the controller's store still reads and writes the bucket, and says its
// observations unread rather than none.
type NoLooks struct{ Err error }
func (n NoLooks) err() error { return fmt.Errorf("the controller's store is not open here: %w", n.Err) }
func (n NoLooks) KeepLooks(context.Context, string, Looks) error { return n.err() }
func (n NoLooks) Looked(context.Context, string) (Looks, bool, error) {
return Looks{}, false, n.err()
}
func (n NoLooks) AllLooks(context.Context) (map[string]Looks, error) { return nil, n.err() }
func (n NoLooks) ForgetLooks(context.Context, string) error { return n.err() }