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() }