diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go index 9f0358033..445ca7e51 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go @@ -312,14 +312,23 @@ func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) { v := BindingState{Licence: r.Licence, Kind: kinds[r.Licence], Generation: r.Generation, Since: co.now.UTC().Format(time.RFC3339), Why: "carried over", By: Itself} var held BindingState - if ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held); err != nil { + ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held) + if err != nil { return t, err - } else if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why { + } + if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why { v.Since = held.Since } if _, err := co.s.BindingsState.Put(r.Consumer, v); err != nil { return t, err } + // Said after its key is written, as every binding's change is (§21), so a reader's first view taken during the + // carry-over is brought up to date by the event. + from := "" + if ok && held.Licence != v.Licence { + from = held.Licence + } + co.m.changed(Binding{Consumer: r.Consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Why: v.Why, By: v.By}, from) t.Written++ } return t, nil diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go index a4be4be5e..29e64f131 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "regexp" "strings" @@ -145,6 +146,16 @@ func TestEveryRowIsCarriedOver(t *testing.T) { if len(mm.audit.under("carried")) != 1 || !c.Ended() { t.Fatal("the carry-over did not say it carried, or did not end") } + // §21: each carried binding is said as binding.changed after its key is written. + said := map[string]bool{} + for _, e := range mm.changedSince(0) { + if e["why"] == "carried over" && e["key"] == KeyFor(fmt.Sprint(e["licence"])) { + said[fmt.Sprint(e["consumer"])] = true + } + } + if !said["laptop"] || !said["server"] || len(mm.changedSince(0)) != 2 { + t.Fatalf("the carry-over said %v", mm.changedSince(0)) + } } // design 39 bus-and-switch §13/AC1: a second start, with carried.postgres present, never opens the database and diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go index 103034053..70a3ad73f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -945,9 +945,24 @@ func TestTodaysRowsAreCarriedOverOnceAndWhole(t *testing.T) { if _, err := conn.Exec(ctx, `alter table offered rename column reason to why`); err != nil { t.Fatal(err) } + m.mu.Lock() + beforeSecond := len(m.of("binding.changed")) + m.mu.Unlock() second := m.start() m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) rec := second.carried() + // §21: every carried binding is said by the start that carries it whole (the stopped one said those it wrote), so a reader's first view taken during the carry-over is brought up to date. + m.mu.Lock() + carriedSaid := 0 + for _, c := range m.of("binding.changed")[beforeSecond:] { + if c["why"] == "carried over" && c["licence"] == licenceA { + carriedSaid++ + } + } + m.mu.Unlock() + if carriedSaid != 4 { + t.Errorf("the carry-over said %d binding.changed, not one per binding", carriedSaid) + } want := map[string][2]int{"licence": {2, 2}, "binding": {4, 4}, "lease": {1, 0}, "offered": {2, 1}, "usage": {2, 2}, "audit": {4, 4}} for table, n := range want { if got := rec.Tables[table]; got.Read != n[0] || got.Written != n[1] { @@ -1017,14 +1032,17 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { m := newWholeMesh(t, dbURL) p := m.start() m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) + m.mu.Lock() + carried := m.count("binding.changed") + m.mu.Unlock() m.vendor.set("at-b", 10, 20) m.vendor.set("at-a", 99, 50) p.must("licences_read_usage", map[string]any{"licence": licenceB}) p.must("licences_read_usage", map[string]any{"licence": licenceA}) - m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= 4 }) + m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= carried+4 }) m.mu.Lock() switched, told := m.of("licence.switched"), m.of("ask seat:operator-channel.tell") - changed := m.of("binding.changed") + changed := m.of("binding.changed")[carried:] m.mu.Unlock() if len(switched) != 1 || switched[0]["from"] != licenceA || switched[0]["to"] != licenceB || switched[0]["by"] != "usage" || len(switched[0]["consumers"].([]any)) != 4 {