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.
162 lines
6.5 KiB
Go
162 lines
6.5 KiB
Go
package conditions
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// **A look that sees a condition as it was writes nothing to the bucket** (novox/hq issue 420, ADR 0289
|
|
// §1 and §4): on 2026-10-10 the bucket took 131 647 writes in four days for twelve open conditions,
|
|
// because every look rewrote the count, the last look and the evidence. The bucket is written when the
|
|
// condition is raised and when what it says changes; the looks are kept beside it, and `conditions` and
|
|
// the events still carry them.
|
|
func TestALookThatChangesNothingWritesNothingToTheBucket(t *testing.T) {
|
|
k, store, told, c := keeper(t)
|
|
ctx := t.Context()
|
|
for range 5 {
|
|
if _, err := k.Observe(ctx, silent("ace")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.pass(30 * time.Second)
|
|
}
|
|
if store.Writes() != 1 {
|
|
t.Fatalf("five looks at one unchanged condition wrote the bucket %d times, want 1 (its raising)", store.Writes())
|
|
}
|
|
got, open, err := k.Get(ctx, "machine.ace.silent")
|
|
if err != nil || !open {
|
|
t.Fatalf("open %v: %v", open, err)
|
|
}
|
|
if got.Observations != 5 || len(got.Evidence) != 5 || !got.LastObserved.Equal(c.now().Add(-30*time.Second)) ||
|
|
got.LooksUnread != "" {
|
|
t.Fatalf("the verb reads %d observation(s), %d evidence line(s), last %s (%q)", got.Observations,
|
|
len(got.Evidence), got.LastObserved, got.LooksUnread)
|
|
}
|
|
list, err := k.Open(ctx)
|
|
if err != nil || len(list) != 1 || list[0].Observations != 5 {
|
|
t.Fatalf("the list reads %+v: %v", list, err)
|
|
}
|
|
|
|
// The bucket's value holds what the condition says, and none of its looks.
|
|
e, _, _ := store.Get(ctx, "machine.ace.silent")
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(e.Value, &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, f := range lookFields {
|
|
if _, held := fields[f]; held {
|
|
t.Errorf("the bucket's value holds %q, which changes on every look", f)
|
|
}
|
|
}
|
|
for _, f := range []string{"key", "kind", "severity", "resolver", "silenced", "raised", "headline", "needs", "epoch"} {
|
|
if _, held := fields[f]; !held {
|
|
t.Errorf("the bucket's value lacks %q", f)
|
|
}
|
|
}
|
|
|
|
// A change of what it says is written, once, and its event carries the looks.
|
|
worse := silent("ace")
|
|
worse.Severity = Urgent
|
|
if _, err := k.Observe(ctx, worse); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := k.Observe(ctx, worse); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.Writes() != 2 {
|
|
t.Fatalf("a change of severity and one more look wrote the bucket %d times in all, want 2", store.Writes())
|
|
}
|
|
said := settled(t, told, 2)
|
|
if said[1].Change != ChangeSeverity || said[1].Observations != 6 || len(said[1].Evidence) != 6 {
|
|
t.Fatalf("the severity's event says %s with %d observation(s) and %d evidence line(s)", said[1].Change,
|
|
said[1].Observations, len(said[1].Evidence))
|
|
}
|
|
|
|
// Its clearing forgets its looks, and its event carries them as they were.
|
|
if cleared, err := k.Clear(ctx, "machine.ace.silent", "heard"); err != nil || !cleared {
|
|
t.Fatalf("cleared %v: %v", cleared, err)
|
|
}
|
|
if _, kept, _ := store.Looked(ctx, "machine.ace.silent"); kept {
|
|
t.Fatal("a cleared condition's looks are still kept")
|
|
}
|
|
said = settled(t, told, 3)
|
|
if said[2].Change != ChangeCleared || said[2].Observations != 7 {
|
|
t.Fatalf("the clearing says %s with %d observation(s)", said[2].Change, said[2].Observations)
|
|
}
|
|
}
|
|
|
|
// **A condition written before its looks moved out keeps its count**: its value says them, the first
|
|
// look counts on from there, and writes the value once without them.
|
|
func TestAConditionWrittenWithItsLooksMovesThemOutOnce(t *testing.T) {
|
|
k, store, _, c := keeper(t)
|
|
ctx := t.Context()
|
|
old := Condition{Key: "machine.ace.silent", Kind: "silent", Subject: Subject{Scope: ScopeMachine, ID: "ace", Machine: "ace"},
|
|
Severity: Warning, Summary: "ace has not been heard from", Source: "S1", Raised: c.now().Add(-time.Hour),
|
|
LastObserved: c.now().Add(-time.Minute), Observations: 7003, Count: 1, Resolver: ResolverSelf,
|
|
Evidence: []Evidence{{At: c.now().Add(-time.Minute), Said: "ace has not been heard from"}}}
|
|
body, _ := json.Marshal(old)
|
|
if err := store.Create(ctx, old.Key, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _, _ := k.Get(ctx, old.Key); got.Observations != 7003 || got.LooksUnread != "" {
|
|
t.Fatalf("before its first look it reads %d observation(s) (%q)", got.Observations, got.LooksUnread)
|
|
}
|
|
for range 3 {
|
|
if _, err := k.Observe(ctx, silent("ace")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.pass(30 * time.Second)
|
|
}
|
|
if store.Writes() != 2 {
|
|
t.Fatalf("three looks wrote the bucket %d times after its creation, want 1", store.Writes()-1)
|
|
}
|
|
got, _, _ := k.Get(ctx, old.Key)
|
|
if got.Observations != 7006 || len(got.Evidence) != 4 {
|
|
t.Fatalf("it counts %d observation(s) and %d evidence line(s), want 7006 and 4", got.Observations, len(got.Evidence))
|
|
}
|
|
}
|
|
|
|
// failingLooks is a controller's store that is away.
|
|
type failingLooks struct{}
|
|
|
|
func (failingLooks) KeepLooks(context.Context, string, Looks) error { return errors.New("store away") }
|
|
func (failingLooks) Looked(context.Context, string) (Looks, bool, error) {
|
|
return Looks{}, false, errors.New("store away")
|
|
}
|
|
func (failingLooks) AllLooks(context.Context) (map[string]Looks, error) {
|
|
return nil, errors.New("store away")
|
|
}
|
|
func (failingLooks) ForgetLooks(context.Context, string) error { return errors.New("store away") }
|
|
|
|
// **A condition is raised while the controller's store is away**, and says its looks are unread rather
|
|
// than none: a store gone is exactly what a condition must still be raised for.
|
|
func TestLooksThatCannotBeKeptAreSaidNotCountedAsNone(t *testing.T) {
|
|
store, told := NewInMemory(), &Told{}
|
|
var said []string
|
|
k := NewKeeper(t.Context(), Options{Store: store, History: store, Teller: told, Looks: failingLooks{},
|
|
Say: func(f string, a ...any) { said = append(said, f) }})
|
|
t.Cleanup(func() { k.Close(context.Background()) })
|
|
if _, err := k.Observe(t.Context(), silent("ace")); err != nil {
|
|
t.Fatalf("a condition was not raised for a store away: %v", err)
|
|
}
|
|
if _, err := k.Observe(t.Context(), silent("ace")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, open, err := k.Get(t.Context(), "machine.ace.silent")
|
|
if err != nil || !open || got.LooksUnread == "" {
|
|
t.Fatalf("open %v, looks unread %q: %v", open, got.LooksUnread, err)
|
|
}
|
|
list, err := k.Open(t.Context())
|
|
if err != nil || len(list) != 1 || list[0].LooksUnread == "" {
|
|
t.Fatalf("the list reads %+v: %v", list, err)
|
|
}
|
|
if e := settled(t, told, 1); e[0].LooksUnread == "" {
|
|
t.Fatal("the raising says nothing of its looks being unkept")
|
|
}
|
|
if len(said) == 0 {
|
|
t.Fatal("looks that could not be kept were not said")
|
|
}
|
|
}
|