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.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// The looks of the open conditions (novox/hq issue 420): see migration 0095. How many times each was
|
||||
// observed, when last, and the newest evidence change on every look, so they are kept here rather than in
|
||||
// the conditions bucket, which is written only when what a condition says changes (ADR 0289 §1).
|
||||
|
||||
// ConditionLooks is one open condition's looks. Evidence is kept as the condition says it, newest first.
|
||||
type ConditionLooks struct {
|
||||
Key string
|
||||
Observations int
|
||||
LastObserved time.Time
|
||||
Evidence json.RawMessage
|
||||
}
|
||||
|
||||
// KeepConditionLooks writes a condition's looks, replacing what its row held. Written under the lease: a
|
||||
// controller that may not act keeps no looks.
|
||||
func (i *Inventory) KeepConditionLooks(ctx context.Context, l ConditionLooks) error {
|
||||
if l.Key == "" {
|
||||
return errors.New("a condition's looks name its key")
|
||||
}
|
||||
epoch, err := i.actingEpoch(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evidence := l.Evidence
|
||||
if len(evidence) == 0 {
|
||||
evidence = json.RawMessage("[]")
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into condition_looks (key, observations, last_observed, evidence, epoch, kept_at)
|
||||
values ($1, $2, $3, $4, $5, now())
|
||||
on conflict (key) do update set observations = excluded.observations,
|
||||
last_observed = excluded.last_observed, evidence = excluded.evidence, epoch = excluded.epoch,
|
||||
kept_at = excluded.kept_at`,
|
||||
l.Key, l.Observations, l.LastObserved, []byte(evidence), epoch)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConditionLooksOf is one condition's looks; false when none are kept.
|
||||
func (i *Inventory) ConditionLooksOf(ctx context.Context, key string) (ConditionLooks, bool, error) {
|
||||
l := ConditionLooks{Key: key}
|
||||
var evidence []byte
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select observations, last_observed, evidence from condition_looks where key = $1`, key).
|
||||
Scan(&l.Observations, &l.LastObserved, &evidence)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ConditionLooks{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ConditionLooks{}, false, err
|
||||
}
|
||||
l.Evidence = evidence
|
||||
return l, true, nil
|
||||
}
|
||||
|
||||
// AllConditionLooks is every kept condition's looks, by key.
|
||||
func (i *Inventory) AllConditionLooks(ctx context.Context) (map[string]ConditionLooks, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select key, observations, last_observed, evidence from condition_looks`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]ConditionLooks{}
|
||||
for rows.Next() {
|
||||
var l ConditionLooks
|
||||
var evidence []byte
|
||||
if err := rows.Scan(&l.Key, &l.Observations, &l.LastObserved, &evidence); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.Evidence = evidence
|
||||
out[l.Key] = l
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ForgetConditionLooks removes a condition's looks: it cleared. Whatever the lease says by now, as a
|
||||
// clearing is: what is not open has nothing to count.
|
||||
func (i *Inventory) ForgetConditionLooks(ctx context.Context, key string) error {
|
||||
_, err := i.store.Pool().Exec(ctx, `delete from condition_looks where key = $1`, key)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A condition's looks are kept in the store, replaced on every look and forgotten when it clears**
|
||||
// (novox/hq issue 420): written under the lease, read back whole.
|
||||
func TestAConditionsLooksAreKeptBesideTheBucket(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
inv.ActsUnder(func(context.Context) (uint64, error) { return 0, errors.New("the lease is not held") })
|
||||
if err := inv.KeepConditionLooks(ctx, ConditionLooks{Key: "machine.ace.silent", Observations: 1,
|
||||
LastObserved: time.Now()}); err == nil {
|
||||
t.Fatal("looks were kept by a process that may not act")
|
||||
}
|
||||
inv.ActsUnder(func(context.Context) (uint64, error) { return 57, nil })
|
||||
at := time.Now().UTC().Truncate(time.Microsecond)
|
||||
for n := 1; n <= 3; n++ {
|
||||
evidence, _ := json.Marshal([]map[string]any{{"at": at, "said": "ace has not been heard from"}})
|
||||
if err := inv.KeepConditionLooks(ctx, ConditionLooks{Key: "machine.ace.silent", Observations: n,
|
||||
LastObserved: at, Evidence: evidence}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.KeepConditionLooks(ctx, ConditionLooks{Key: "machine.ace.late", Observations: 2,
|
||||
LastObserved: at}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, kept, err := inv.ConditionLooksOf(ctx, "machine.ace.silent")
|
||||
if err != nil || !kept || got.Observations != 3 || !got.LastObserved.Equal(at) {
|
||||
t.Fatalf("read %+v kept %v: %v", got, kept, err)
|
||||
}
|
||||
var evidence []map[string]any
|
||||
if err := json.Unmarshal(got.Evidence, &evidence); err != nil || len(evidence) != 1 {
|
||||
t.Fatalf("evidence %s: %v", got.Evidence, err)
|
||||
}
|
||||
all, err := inv.AllConditionLooks(ctx)
|
||||
if err != nil || len(all) != 2 || all["machine.ace.late"].Observations != 2 ||
|
||||
string(all["machine.ace.late"].Evidence) != "[]" {
|
||||
t.Fatalf("all %+v: %v", all, err)
|
||||
}
|
||||
if err := inv.ForgetConditionLooks(ctx, "machine.ace.silent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, kept, err := inv.ConditionLooksOf(ctx, "machine.ace.silent"); kept || err != nil {
|
||||
t.Fatalf("a cleared condition's looks kept %v: %v", kept, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
-- The looks of an open condition are kept here, not in the conditions bucket (novox/hq issue 420, ADR 0289
|
||||
-- §1 and §4).
|
||||
--
|
||||
-- On 2026-10-10 the bus had stored 131 647 writes to the conditions bucket in four days while twelve
|
||||
-- conditions were open: every look rewrote a condition's value, because the value carried how many times it
|
||||
-- had been observed, when last and the newest evidence. A watcher heard a write for every look, not for
|
||||
-- every change. The bucket now holds what a condition says, written when that changes; how often and when
|
||||
-- it was last seen, and what each look said, change on every look and are kept in this row, written by the
|
||||
-- serving controller under its lease (`epoch`) and read back by `conditions` and the events it says.
|
||||
--
|
||||
-- One row per open condition, removed when it clears. `evidence` is the newest observations, newest first,
|
||||
-- as the condition's `evidence` says them: [{"at", "said"}].
|
||||
create table condition_looks (
|
||||
key text primary key,
|
||||
observations integer not null check (observations >= 0),
|
||||
last_observed timestamptz not null,
|
||||
evidence jsonb not null default '[]',
|
||||
epoch bigint,
|
||||
kept_at timestamptz not null default now()
|
||||
);
|
||||
Reference in New Issue
Block a user