Merge pull request 'The licence manager counts room from the weekly window alone, so the switch finds a target (issue 1409)' (#661) from fix/1409-weekly-only into main
This commit was merged in pull request #661.
This commit is contained in:
@@ -55,10 +55,10 @@ reads alone and answers every act with "the carry-over has not ended"; a failed
|
||||
## Switching on usage
|
||||
|
||||
Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session,
|
||||
weekly and sonnet windows, each with its reset time. A licence's room is 100 less the higher of its session and weekly
|
||||
percentages, known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
|
||||
a licence with consumers at or above `switch_at_pct`, every consumer bound to it moves, in one act, to the licence
|
||||
with the most room whose windows are both below `room_below_pct`: one `binding.changed` per consumer, one
|
||||
weekly and sonnet windows, each with its reset time. A licence's room is 100 less its weekly percentage alone (the session
|
||||
window takes no part in room, the switch, the warning or the conditions: the vendor retired the session limit), known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
|
||||
a licence with consumers at or above `switch_at_pct` in its weekly window, every consumer bound to it moves, in one act, to the licence
|
||||
with the most room whose weekly window is below `room_below_pct`: one `binding.changed` per consumer, one
|
||||
`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule.
|
||||
|
||||
Its conditions are said on the operator channel's condition subject, the router deciding where they go:
|
||||
|
||||
@@ -227,16 +227,16 @@ func (m *Manager) unread(l Licence, s Settings) (string, bool) {
|
||||
func (m *Manager) roomWords(o Licence, s Settings) string {
|
||||
room, known := Room(o, m.Now(), s)
|
||||
switch {
|
||||
case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow):
|
||||
return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow)
|
||||
case known && *o.Usage.WeeklyPct >= s.RoomBelow:
|
||||
return fmt.Sprintf("has %g %% room, its weekly window not below %g %%", room, s.RoomBelow)
|
||||
case known:
|
||||
return fmt.Sprintf("has %g %% room", room)
|
||||
case o.Kind != "subscription":
|
||||
return "is an API key, whose room is never known"
|
||||
case o.Usage == nil || o.Usage.ReadAt == "":
|
||||
return "has no reading"
|
||||
case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil:
|
||||
return "has no session or weekly window in its reading"
|
||||
case o.Usage.WeeklyPct == nil:
|
||||
return "has no weekly window in its reading"
|
||||
case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()):
|
||||
return "does not refresh"
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := fourOnA(t)
|
||||
_, said := saying(mm)
|
||||
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 20)))
|
||||
_ = mm.store.SaveLicence(ctx, sub("b", read(20, 95)))
|
||||
_ = mm.store.SaveLicence(ctx, sub("c", read(40, 99)))
|
||||
for range 2 {
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
@@ -158,7 +158,7 @@ func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
|
||||
if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" ||
|
||||
c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" ||
|
||||
!strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") ||
|
||||
!strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") ||
|
||||
!strings.Contains(c.Explanation, "b has 5 % room, its weekly window not below 90 %") ||
|
||||
c.Resolved != "ace, g14, laptop, server have a licence with room again" {
|
||||
t.Fatalf("no-room %+v", c)
|
||||
}
|
||||
@@ -178,7 +178,7 @@ func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
|
||||
key := "licence.a.near-limit"
|
||||
var last oc.Condition
|
||||
for _, w := range []float64{80, 89, 91, 91, 95} {
|
||||
_ = mm.store.SaveLicence(ctx, sub("a", read(10, w)))
|
||||
_ = mm.store.SaveLicence(ctx, sub("a", read(95, w))) // a session at 95 % takes no part (C4)
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
for _, s := range *said {
|
||||
if s.Key == key {
|
||||
@@ -191,8 +191,8 @@ func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
|
||||
!strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") {
|
||||
t.Fatalf("said %v, last %+v", of(*said, key), last)
|
||||
}
|
||||
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 0)))
|
||||
_ = mm.store.SaveLicence(ctx, sub("c", read(95, 0)))
|
||||
_ = mm.store.SaveLicence(ctx, sub("b", read(0, 95)))
|
||||
_ = mm.store.SaveLicence(ctx, sub("c", read(0, 95)))
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" {
|
||||
t.Fatalf("with no target said %+v", c)
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
package main
|
||||
|
||||
// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in
|
||||
// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
|
||||
// A licence's room (design 39 bus-and-switch §25, §48, C4): 100 less its weekly percentage in its latest reading, the
|
||||
// session window taking no part (the vendor retired the session limit), known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
|
||||
// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known,
|
||||
// so it is never a target.
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
// Room is a licence's room at now, and whether it is known.
|
||||
func Room(l Licence, now time.Time, s Settings) (float64, bool) {
|
||||
if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil {
|
||||
if l.Kind != "subscription" || l.Usage == nil || l.Usage.WeeklyPct == nil {
|
||||
return 0, false
|
||||
}
|
||||
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
|
||||
@@ -22,5 +19,5 @@ func Room(l Licence, now time.Time, s Settings) (float64, bool) {
|
||||
if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) {
|
||||
return 0, false
|
||||
}
|
||||
return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true
|
||||
return 100 - *l.Usage.WeeklyPct, true
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestAFailedReadingKeepsThePercentages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives.
|
||||
// design 39 bus-and-switch §25/AC1, §48, C4: a table of readings, ages and healths and the room each gives.
|
||||
func TestTheRoomOfEachLicence(t *testing.T) {
|
||||
now := t0
|
||||
pct := func(v float64) *float64 { return &v }
|
||||
@@ -102,8 +102,10 @@ func TestTheRoomOfEachLicence(t *testing.T) {
|
||||
room float64
|
||||
known bool
|
||||
}{
|
||||
{"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
|
||||
{"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true},
|
||||
{"the weekly window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
|
||||
{"the session takes no part (C4)", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 80, true},
|
||||
{"no session window, weekly 33 (C4)", Licence{Kind: "subscription", Usage: &UsageReading{WeeklyPct: pct(33),
|
||||
ReadAt: now.Format(time.RFC3339Nano)}}, 67, true},
|
||||
{"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true},
|
||||
{"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false},
|
||||
{"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package main
|
||||
|
||||
// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and
|
||||
// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every
|
||||
// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below
|
||||
// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
|
||||
// L, with one or more consumers bound to it, is at or above switch_at_pct in its weekly window (C4: the session window takes
|
||||
// no part), every consumer bound to L moves, in one act, to the other licence with known room whose weekly window is
|
||||
// below room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
|
||||
// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off
|
||||
// a licence at its threshold, so none flaps between two.
|
||||
|
||||
@@ -61,24 +61,17 @@ func consumersOf(licence string, bindings []Binding) []string {
|
||||
return consumers
|
||||
}
|
||||
|
||||
// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name,
|
||||
// percentage and reset; ok false when neither is.
|
||||
// windowAt is l's weekly window when it is at or above pct (C4: the session window takes no part): its name,
|
||||
// percentage and reset; ok false when it is not.
|
||||
func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) {
|
||||
if u == nil {
|
||||
if u == nil || u.WeeklyPct == nil || *u.WeeklyPct < pct {
|
||||
return "", 0, "", false
|
||||
}
|
||||
session, weekly := u.SessionPct, u.WeeklyPct
|
||||
switch {
|
||||
case session != nil && *session >= pct && (weekly == nil || *session >= *weekly):
|
||||
return "session", *session, u.SessionResetsAt, true
|
||||
case weekly != nil && *weekly >= pct:
|
||||
return "weekly", *weekly, u.WeeklyResetsAt, true
|
||||
}
|
||||
return "", 0, "", false
|
||||
return "weekly", *u.WeeklyPct, u.WeeklyResetsAt, true
|
||||
}
|
||||
|
||||
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below
|
||||
// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room.
|
||||
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose weekly window is below
|
||||
// room_below_pct (C4), the one with the most room, the first by name on a tie; empty when none has room.
|
||||
func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) {
|
||||
others := append([]Licence(nil), licences...)
|
||||
sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name })
|
||||
@@ -88,7 +81,7 @@ func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string,
|
||||
continue
|
||||
}
|
||||
room, known := Room(o, m.Now(), s)
|
||||
if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
|
||||
if !known || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
|
||||
continue
|
||||
}
|
||||
best, to = room, o.Name
|
||||
|
||||
@@ -14,11 +14,18 @@ func read(session, weekly float64) *UsageReading {
|
||||
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
// weekly is a reading with no session window, as the vendor answers since the session limit was retired (C4).
|
||||
func weekly(pct float64) *UsageReading {
|
||||
u := read(0, pct)
|
||||
u.SessionPct, u.SessionResetsAt = nil, ""
|
||||
return u
|
||||
}
|
||||
|
||||
func sub(name string, u *UsageReading) Licence {
|
||||
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives.
|
||||
// design 39 bus-and-switch §26/AC1, §25, §48, C4: a table of licences, readings and bindings and the moves each gives.
|
||||
func TestTheRuleMovesEachTable(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
on := func(consumers ...string) []Binding {
|
||||
@@ -41,12 +48,15 @@ func TestTheRuleMovesEachTable(t *testing.T) {
|
||||
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
|
||||
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
|
||||
on("x", "y", "z", "w"), "c", "weekly", false, 4},
|
||||
{"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1},
|
||||
{"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))},
|
||||
on("x"), "b", "session", false, 1},
|
||||
{"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)),
|
||||
sub("c", read(89, 89))}, on("x"), "c", "session", false, 1},
|
||||
{"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)),
|
||||
{"the session window at 100 moves nothing (C4)", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))},
|
||||
on("x"), "", "", false, 0},
|
||||
{"no session window, weekly 99, to weekly 33 (C4)", []Licence{sub("a", weekly(99)), sub("b", weekly(33))},
|
||||
on("x", "y"), "b", "weekly", false, 2},
|
||||
{"a tie goes to the first by name", []Licence{sub("a", read(0, 99)), sub("c", read(10, 10)), sub("b", read(10, 10))},
|
||||
on("x"), "b", "weekly", false, 1},
|
||||
{"a target's weekly below room_below_pct, its session not counted (C4)", []Licence{sub("a", read(0, 99)),
|
||||
sub("b", read(0, 90)), sub("c", read(95, 89))}, on("x"), "c", "weekly", false, 1},
|
||||
{"no other licence has room", []Licence{sub("a", read(0, 99)), sub("b", read(0, 95)),
|
||||
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
|
||||
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
|
||||
} {
|
||||
@@ -65,7 +75,7 @@ func TestTheRuleMovesEachTable(t *testing.T) {
|
||||
func fourOnA(t *testing.T) *miniMesh {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} {
|
||||
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 30)), sub("c", read(40, 40))} {
|
||||
_ = mm.store.SaveLicence(ctx, l)
|
||||
}
|
||||
for _, c := range []string{"ace", "g14", "laptop", "server"} {
|
||||
|
||||
+15
-14
@@ -211,13 +211,13 @@ func fillTodaysRows(t *testing.T, conn *pgx.Conn) {
|
||||
type vendor struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
logins map[string][2]string // refresh token: the next access token and the account
|
||||
usage map[string][2]float64 // access token: session and weekly percentages
|
||||
logins map[string][2]string // refresh token: the next access token and the account
|
||||
usage map[string]float64 // access token: the weekly percentage; the session window is answered empty (C4)
|
||||
server *httptest.Server
|
||||
}
|
||||
|
||||
func newVendor(t *testing.T) *vendor {
|
||||
v := &vendor{logins: map[string][2]string{}, usage: map[string][2]float64{}}
|
||||
v := &vendor{logins: map[string][2]string{}, usage: map[string]float64{}}
|
||||
v.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
@@ -240,16 +240,17 @@ func newVendor(t *testing.T) *vendor {
|
||||
return
|
||||
}
|
||||
resets := time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": u[0], "resets_at": resets},
|
||||
"seven_day": map[string]any{"utilization": u[1], "resets_at": resets}})
|
||||
// As the vendor answers since it retired the session limit: the session window with no utilization (C4).
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": nil, "resets_at": nil},
|
||||
"seven_day": map[string]any{"utilization": u, "resets_at": resets}})
|
||||
}))
|
||||
t.Cleanup(v.server.Close)
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *vendor) set(access string, session, weekly float64) {
|
||||
func (v *vendor) set(access string, weekly float64) {
|
||||
v.mu.Lock()
|
||||
v.usage[access] = [2]float64{session, weekly}
|
||||
v.usage[access] = weekly
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -1065,8 +1066,8 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
|
||||
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)
|
||||
m.vendor.set("at-b", 20)
|
||||
m.vendor.set("at-a", 99)
|
||||
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") >= carried+4 })
|
||||
@@ -1098,8 +1099,8 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
|
||||
|
||||
// Every licence at its threshold: the consumers have nowhere to go.
|
||||
noRoom := "licence." + KeyFor(licenceB) + ".no-room"
|
||||
m.vendor.set("at-a", 99, 50)
|
||||
m.vendor.set("at-b", 99, 60)
|
||||
m.vendor.set("at-a", 99)
|
||||
m.vendor.set("at-b", 99)
|
||||
p.must("licences_read_usage", map[string]any{"licence": licenceA})
|
||||
p.must("licences_read_usage", map[string]any{"licence": licenceB})
|
||||
m.until("no-room is raised", func() bool { return len(m.conditionsOf(noRoom)) > 0 })
|
||||
@@ -1110,7 +1111,7 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
|
||||
t.Errorf("no-room said %+v", r)
|
||||
}
|
||||
// A reading with room: the next reading of the full licence moves its consumers, and no-room is cleared.
|
||||
m.vendor.set("at-a", 5, 30)
|
||||
m.vendor.set("at-a", 30)
|
||||
p.must("licences_read_usage", map[string]any{"licence": licenceA})
|
||||
p.must("licences_read_usage", map[string]any{"licence": licenceB})
|
||||
m.until("no-room is cleared", func() bool {
|
||||
@@ -1159,8 +1160,8 @@ func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) {
|
||||
}
|
||||
|
||||
// The full licence is read first: nothing has room yet, so its consumer stays.
|
||||
m.vendor.set("at-old", 99, 40)
|
||||
m.vendor.set("at-team", 10, 5)
|
||||
m.vendor.set("at-old", 99)
|
||||
m.vendor.set("at-team", 5)
|
||||
p.must("licences_read_usage", map[string]any{"licence": licenceA})
|
||||
p.must("licences_read_usage", map[string]any{"licence": "team@example.org"})
|
||||
var listed []map[string]any
|
||||
|
||||
@@ -186,13 +186,13 @@
|
||||
},
|
||||
"switch_auto": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
|
||||
"description": "Whether the manager moves every consumer off a licence whose weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
|
||||
"trusted": true,
|
||||
"default": true
|
||||
},
|
||||
"switch_at_pct": {
|
||||
"type": "number",
|
||||
"description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
|
||||
"description": "The weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"trusted": true,
|
||||
@@ -200,7 +200,7 @@
|
||||
},
|
||||
"room_below_pct": {
|
||||
"type": "number",
|
||||
"description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
|
||||
"description": "What the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"trusted": true,
|
||||
@@ -208,7 +208,7 @@
|
||||
},
|
||||
"warn_at_pct": {
|
||||
"type": "number",
|
||||
"description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.",
|
||||
"description": "The weekly usage of a licence with consumers, in percent, at which it is warned about once.",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"trusted": true,
|
||||
|
||||
Reference in New Issue
Block a user