Derive data protection from a module's declared data (hq ADR 0233)

A module's data section says what it keeps and how precious it is; the backup holder's lines,
binding stickiness, retirement on unassign and D13's conditions follow from it, so issue 273's
empty replacement is said and an unassigned module's data is remembered, not forgotten.
This commit is contained in:
jochen
2026-10-06 16:47:49 +02:00
parent c988d6d7be
commit 52af210e47
26 changed files with 2957 additions and 130 deletions
+109
View File
@@ -0,0 +1,109 @@
package inventory
import (
"testing"
"time"
)
func size(n int64) *int64 { return &n }
func at(t time.Time) *time.Time { return &t }
// What a machine declares is kept with what its holder measured; an irreplaceable item it no longer
// declares — its module unassigned — is RETIRED and kept, never forgotten, and comes back as it was when
// declared again; anything else no longer declared is forgotten (novox/hq ADR 0233).
func TestAnUndeclaredIrreplaceableItemIsRetiredNotForgotten(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
declared := []DeclaredData{
{Module: "home-assistant", Item: "config", Class: "irreplaceable", Owned: true},
{Module: "searxng", Item: "valkey", Class: "cache"},
{Module: "ollama", Item: "models", Class: "rebuildable"},
}
measured := map[string]map[string]Measurement{"home-assistant": {"config": {Path: "/var/lib/home-assistant/config",
Size: size(900 << 20), LastWrite: at(now.Add(-time.Minute)), MeasuredAt: at(now), LastBackup: at(now.Add(-6 * time.Hour))}}}
if _, err := inv.RecordData(ctx, "home", declared, measured, "", now); err != nil {
t.Fatal(err)
}
all, err := inv.Data(ctx)
if err != nil || len(all) != 3 {
t.Fatalf("%+v, %v", all, err)
}
// Unassigned: nothing of it is declared any more.
later := now.Add(time.Hour)
change, err := inv.RecordData(ctx, "home", nil, nil, "home-assistant unassigned", later)
if err != nil {
t.Fatal(err)
}
if len(change.Retired) != 1 || change.Retired[0].Item != "config" {
t.Fatalf("retired %+v", change.Retired)
}
all, err = inv.Data(ctx)
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || !all[0].Retired() || all[0].Path != "/var/lib/home-assistant/config" ||
*all[0].Size != 900<<20 || all[0].RetiredWhy != "home-assistant unassigned" {
t.Fatalf("the irreplaceable item is not kept retired with where it is: %+v", all)
}
// A second run that still does not declare it changes nothing: retired once, not again.
change, err = inv.RecordData(ctx, "home", nil, nil, "again", later.Add(time.Hour))
if err != nil || len(change.Retired) != 0 {
t.Fatalf("retired twice: %+v, %v", change, err)
}
// Deleting needs it retired; assigned again it is not retired any more, and its history stays.
if err := inv.MarkDataDeleted(ctx, "home", "searxng", "valkey", "p", "w", later); err == nil {
t.Fatal("deleting something the mesh does not hold retired was recorded")
}
change, err = inv.RecordData(ctx, "home", declared[:1], nil, "", later.Add(2*time.Hour))
if err != nil || len(change.Reenabled) != 1 {
t.Fatalf("declared again: %+v, %v", change, err)
}
r, err := inv.DataOf(ctx, "home", "home-assistant", "config")
if err != nil || r.Retired() || !r.FirstSeen.Equal(now) || r.Size == nil {
t.Fatalf("declared again lost what was known: %+v, %v", r, err)
}
// Retired and then deleted by a person: recorded, never by dropping the row.
if _, err := inv.RecordData(ctx, "home", nil, nil, "unassigned again", later.Add(3*time.Hour)); err != nil {
t.Fatal(err)
}
if err := inv.MarkDataDeleted(ctx, "home", "home-assistant", "config", "jochen", "moved to the anchor", later.Add(4*time.Hour)); err != nil {
t.Fatal(err)
}
r, err = inv.DataOf(ctx, "home", "home-assistant", "config")
if err != nil || r.DeletedAt == nil || r.DeletedBy != "jochen" || r.Retired() {
t.Fatalf("a deletion is not on record: %+v, %v", r, err)
}
}
// A reading is kept at most once an hour, and the peak is read over the window asked.
func TestReadingsAreHourlyAndThePeakIsTheLargest(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
declared := []DeclaredData{{Module: "grafana", Item: "data", Class: "valuable", Owned: true}}
for i, s := range []int64{100, 120, 999, 130, 40} {
when := now.Add(time.Duration(i) * 20 * time.Minute)
if _, err := inv.RecordData(ctx, "ace", declared, map[string]map[string]Measurement{"grafana": {"data": {
Path: "/var/lib/grafana/data", Size: size(s), MeasuredAt: at(when)}}}, "", when); err != nil {
t.Fatal(err)
}
}
var n int
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from data_reading`).Scan(&n); err != nil || n != 2 {
t.Fatalf("%d readings kept for five measurements over 80 minutes, want 2 (one an hour): %v", n, err)
}
peaks, err := inv.DataPeaks(ctx, now.Add(-time.Hour))
if err != nil || peaks["ace/grafana/data"] != 130 {
t.Fatalf("peak %v, %v", peaks, err)
}
r, _ := inv.DataOf(ctx, "ace", "grafana", "data")
if r.Size == nil || *r.Size != 40 {
t.Fatalf("the newest measurement is not the one on record: %+v", r)
}
}