From dccae4b0e78ee19d7415fbbf993d15f88266a2fb Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:54:01 +0200 Subject: [PATCH] Make the messenger's per-channel hourly cap the setting messages-per-hour, default 100 (issue 1476) --- .../cmd/messenger/cap_setting_test.go | 43 +++++++++++++++ modules/messenger/cmd/messenger/holder.go | 22 ++++++-- .../messenger/cmd/messenger/holder_test.go | 53 +++++++++++++------ modules/messenger/cmd/messenger/main.go | 13 ++++- modules/messenger/cmd/messenger/outbox.go | 4 +- modules/messenger/cmd/messenger/status.go | 2 +- modules/messenger/files/settings.json | 3 +- modules/messenger/module.json | 10 +++- 8 files changed, 124 insertions(+), 26 deletions(-) create mode 100644 modules/messenger/cmd/messenger/cap_setting_test.go diff --git a/modules/messenger/cmd/messenger/cap_setting_test.go b/modules/messenger/cmd/messenger/cap_setting_test.go new file mode 100644 index 00000000..58b9ae10 --- /dev/null +++ b/modules/messenger/cmd/messenger/cap_setting_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestTheCapFollowsTheSetting(t *testing.T) { + h, tg, _, c, _ := newHolder(t) + h.Desk = nil + h.CapPerHour = func() int { return 3 } + for i := 0; i < 5; i++ { + h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent")) + c.pass(BurstWindow + time.Second) + h.Flush() + } + if len(tg.sends) != 4 || !strings.Contains(tg.sends[3].Body, "sent 3 messages") { + t.Fatalf("sent %d, want 3 and the cap said", len(tg.sends)) + } + if cap := h.Status("listening").Channels[0].Cap; cap != 3 { + t.Fatalf("status cap %d", cap) + } +} + +func TestMessagesPerHourIsReadFromTheSettingsFile(t *testing.T) { + read := func(body string) (Settings, error) { + p := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return readSettings(p) + } + if s, err := read(`{"messages-per-hour": 100}`); err != nil || s.MessagesPerHour != 100 { + t.Fatalf("set: %+v %v", s, err) + } + if s, err := read(`{"time-zone": ""}`); err != nil || s.MessagesPerHour != 0 { + t.Fatalf("unset: %+v %v", s, err) + } +} diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 79cbb1b8..d8fb7f8a 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -72,10 +72,11 @@ const ( // ClearSettle: a clearing is said once the condition stayed cleared this long. One raised again sooner // (a unit failing again after each apply, issue 373) continues its message without a word, and its // clearing is never said. Once it has come back, its next clearing waits the reopen window less a minute. - ClearSettle = 2 * time.Minute - CapPerHour = 20 - KeptSends = 200 - KeptRefusals = 50 + ClearSettle = 2 * time.Minute + // DefaultCapPerHour: the messages one channel gets in an hour when the setting messages-per-hour gives none. + DefaultCapPerHour = 100 + KeptSends = 200 + KeptRefusals = 50 // FreshFor: an event older than this by its own time is history — state, never a message. FreshFor = 10 * time.Minute @@ -323,6 +324,9 @@ type Holder struct { // NeedsOnly says whether a kind is set to say only what needs the operator (the setting // `only-what-needs-me`, ADR 0308 §4); nil, none is. NeedsOnly func(kind string) bool + // CapPerHour is the most messages one channel gets an hour (the setting messages-per-hour, issue 1476); + // nil, or a value below one, is DefaultCapPerHour. TellCapPerHour is its own and stays fixed. + CapPerHour func() int work sync.Mutex // one event, call or tick at a time mu sync.Mutex // what the status reads @@ -1207,3 +1211,13 @@ func withheldLine(line string, refusal Refusal, passes func(string) (Refusal, bo } return "(a line carrying " + refusal.What + " is withheld here)" } + +// capPerHour is the cap now in force on a channel's messages an hour. +func (h *Holder) capPerHour() int { + if h.CapPerHour != nil { + if n := h.CapPerHour(); n >= 1 { + return n + } + } + return DefaultCapPerHour +} diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index f4cfd76e..980721c3 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -205,6 +205,8 @@ func TestAWarningGoesToTheDesktopWhenASessionAnswersElseTelegram(t *testing.T) { func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) { h, tg, _, c, _ := newHolder(t) h.Desk = nil + const capHere = 20 + h.CapPerHour = func() int { return capHere } // Urgent conditions, each alone and after a quiet half minute: each is its own message — until // the cap, which is said once, in a message of its own, and holds the rest. for i := 0; i < 25; i++ { @@ -212,27 +214,27 @@ func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) { c.pass(BurstWindow + time.Second) h.Flush() } - if len(tg.sends) != CapPerHour+1 { - t.Fatalf("sent %d, want the cap of %d and one saying so", len(tg.sends), CapPerHour) + if len(tg.sends) != capHere+1 { + t.Fatalf("sent %d, want the cap of %d and one saying so", len(tg.sends), capHere) } - if last := tg.sends[CapPerHour]; !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") { + if last := tg.sends[capHere]; !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") { t.Fatalf("the cap was not said: %q", last.Title) } st := h.Status("listening") - if st.Channels[0].Held != 5 || st.Channels[0].SentLastHour != CapPerHour { + if st.Channels[0].Held != 5 || st.Channels[0].SentLastHour != capHere { t.Fatalf("status: %+v", st.Channels[0]) } for i := 0; i < 10; i++ { c.pass(time.Minute) h.Tick() } - if len(tg.sends) != CapPerHour+1 { + if len(tg.sends) != capHere+1 { t.Fatalf("said the cap again or sent past it: %d", len(tg.sends)) } // The hour frees the channel: what was held goes out as one digest. c.pass(time.Hour) h.Tick() - if len(tg.sends) != CapPerHour+2 { + if len(tg.sends) != capHere+2 { t.Fatalf("sends %d", len(tg.sends)) } d := tg.sends[len(tg.sends)-1] @@ -579,14 +581,14 @@ func TestATellIsDroppedPastItsOwnHourlyCap(t *testing.T) { func TestATellDoesNotCountTowardTheConditionsCap(t *testing.T) { h, tg, _, c, _ := newHolder(t) h.Desk = nil - // Bring conditions close to, but under, their own cap: CapPerHour-5. - for i := 0; i < CapPerHour-5; i++ { + // Bring conditions close to, but under, their own cap: DefaultCapPerHour-5. + for i := 0; i < DefaultCapPerHour-5; i++ { h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent")) c.pass(BurstWindow + time.Second) h.Flush() } - if len(tg.sends) != CapPerHour-5 { - t.Fatalf("sent %d conditions, want %d", len(tg.sends), CapPerHour-5) + if len(tg.sends) != DefaultCapPerHour-5 { + t.Fatalf("sent %d conditions, want %d", len(tg.sends), DefaultCapPerHour-5) } // A full hour of tells: these must not be counted against the conditions' cap (ADR 0380 §8). for i := 0; i < TellCapPerHour; i++ { @@ -597,7 +599,7 @@ func TestATellDoesNotCountTowardTheConditionsCap(t *testing.T) { h.Condition(EventRaised, cond("machine.mlast.silent", Urgent, "a machine is silent")) c.pass(BurstWindow + time.Second) h.Flush() - want := CapPerHour - 5 + TellCapPerHour + 1 + want := DefaultCapPerHour - 5 + TellCapPerHour + 1 if len(tg.sends) != want { t.Fatalf("sent %d, want %d (the condition after the tells still sent)", len(tg.sends), want) } @@ -644,14 +646,14 @@ func TestATellCapDropIsLoggedOnceAnHour(t *testing.T) { func TestAFloodOfDroppedTellsDoesNotEvictTheConditionsCapHistory(t *testing.T) { h, tg, _, c, _ := newHolder(t) h.Desk = nil - // Fill the conditions' cap exactly: CapPerHour sent, none held yet. - for i := 0; i < CapPerHour; i++ { + // Fill the conditions' cap exactly: DefaultCapPerHour sent, none held yet. + for i := 0; i < DefaultCapPerHour; i++ { h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent")) c.pass(BurstWindow + time.Second) h.Flush() } - if len(tg.sends) != CapPerHour { - t.Fatalf("sent %d conditions, want the cap of %d", len(tg.sends), CapPerHour) + if len(tg.sends) != DefaultCapPerHour { + t.Fatalf("sent %d conditions, want the cap of %d", len(tg.sends), DefaultCapPerHour) } // Use up the tell cap, then flood 300 more: all past TellCapPerHour are dropped, far more than KeptSends. @@ -670,7 +672,7 @@ func TestAFloodOfDroppedTellsDoesNotEvictTheConditionsCapHistory(t *testing.T) { h.Condition(EventRaised, cond("machine.mlast.silent", Urgent, "a machine is silent")) c.pass(BurstWindow + time.Second) h.Flush() - want := CapPerHour + TellCapPerHour + 1 // the cap's own conditions, the tells that did send, and the cap message + want := DefaultCapPerHour + TellCapPerHour + 1 // the cap's own conditions, the tells that did send, and the cap message if len(tg.sends) != want { t.Fatalf("sent %d, want %d (the cap still held after a flood of dropped tells)", len(tg.sends), want) } @@ -688,3 +690,22 @@ func TestATestMessageRespectsTheRule(t *testing.T) { t.Fatalf("%v", out) } } + +// The cap is a setting, messages-per-hour (issue 1476), and 100 when none is given: the 21st to the 100th +// message of an hour still goes out, the 101st is held and said so with the real number. +func TestTheCapUnsetIsAHundredAnHour(t *testing.T) { + h, tg, _, c, _ := newHolder(t) + h.Desk = nil + for i := 0; i < 101; i++ { + h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent")) + c.pass(BurstWindow + time.Second) + h.Flush() + } + if len(tg.sends) != 101 { + t.Fatalf("sent %d, want 100 and one saying the cap", len(tg.sends)) + } + last := tg.sends[100] + if !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") || !strings.Contains(last.Body, "sent 100 messages") { + t.Fatalf("the cap was not said with its number: %q %q", last.Title, last.Body) + } +} diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index 649e90a5..24be3b0f 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -32,6 +32,9 @@ type Settings struct { // OnlyWhatNeedsMe are the channel kinds set to say only what needs the operator: a walk that delivered // with nothing held and nothing open is not told there at all (novox/hq ADR 0308 §4). OnlyWhatNeedsMe []string `json:"only-what-needs-me"` + // MessagesPerHour is the most messages one channel gets in an hour (issue 1476); 0, not given, is + // DefaultCapPerHour. + MessagesPerHour int `json:"messages-per-hour"` } // zoneOf is the location a time-zone setting names, or this machine's own when it names none or one @@ -63,6 +66,13 @@ func readSettings(path string) (Settings, error) { if tz, ok := loose["time-zone"].(string); ok { s.TimeZone = strings.TrimSpace(tz) } + switch v := loose["messages-per-hour"].(type) { + case nil: + case float64: + s.MessagesPerHour = int(v) + default: + return s, fmt.Errorf("messages-per-hour is not a number") + } switch v := loose["only-what-needs-me"].(type) { case nil: case []any: @@ -330,7 +340,8 @@ func main() { } return loc }, - Emit: func(event string, body any) error { return stdio.Emit(event, body) }, + Emit: func(event string, body any) error { return stdio.Emit(event, body) }, + CapPerHour: func() int { return settings().MessagesPerHour }, NeedsOnly: func(kind string) bool { for _, k := range settings().OnlyWhatNeedsMe { if k == kind { diff --git a/modules/messenger/cmd/messenger/outbox.go b/modules/messenger/cmd/messenger/outbox.go index ab11d4d3..8eb47538 100644 --- a/modules/messenger/cmd/messenger/outbox.go +++ b/modules/messenger/cmd/messenger/outbox.go @@ -318,7 +318,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { if said.IsZero() || now.Sub(said) >= time.Hour { m := Message{Title: "Mesh messages paused for this hour", Body: fmt.Sprintf("The mesh sent %d messages here this hour, its limit. %d more are held and come as "+ - "one message when the hour allows.", CapPerHour, len(entries)), Urgent: true} + "one message when the hour allows.", h.capPerHour(), len(entries)), Urgent: true} _, err := ch.Send(m) h.noteChannel(name, err) if err == nil { @@ -613,7 +613,7 @@ func (h *Holder) sendNow(ch Channel, key, what string, m Message) bool { // tell is not counted here: it has its own cap (TellCapPerHour) so a burst of tells cannot crowd // out a condition or an ask (ADR 0380 §8). func (h *Holder) allow(channel string) bool { - return h.sentLastHour(channel) < CapPerHour + return h.sentLastHour(channel) < h.capPerHour() } func (h *Holder) sentLastHour(channel string) int { diff --git a/modules/messenger/cmd/messenger/status.go b/modules/messenger/cmd/messenger/status.go index e4192a76..8680ac07 100644 --- a/modules/messenger/cmd/messenger/status.go +++ b/modules/messenger/cmd/messenger/status.go @@ -47,7 +47,7 @@ func (h *Holder) Status(listening string) Status { now := h.Now() var st Status for _, ch := range h.kindChannels() { - cs := ChannelStatus{Name: ch.Name(), Cap: CapPerHour, SentLastHour: h.sentLastHour(ch.Name())} + cs := ChannelStatus{Name: ch.Name(), Cap: h.capPerHour(), SentLastHour: h.sentLastHour(ch.Name())} if err := ch.Ready(); err != nil { cs.NotReady = err.Error() } else { diff --git a/modules/messenger/files/settings.json b/modules/messenger/files/settings.json index ce76334c..6d22854a 100644 --- a/modules/messenger/files/settings.json +++ b/modules/messenger/files/settings.json @@ -1,4 +1,5 @@ { "time-zone": "", - "only-what-needs-me": [] + "only-what-needs-me": [], + "messages-per-hour": 100 } diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 8501bd4c..6e7f1adf 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -132,6 +132,14 @@ "description": "The kinds of channel set to say only what needs the operator: a walk that delivered with nothing held and nothing open is not told there (ADR 0308 §4); empty is none.", "trusted": true, "default": [] + }, + "messages-per-hour": { + "type": "number", + "description": "The most messages the mesh sends one channel in an hour, in messages per channel per hour; past it the rest are held and said once as one message (not the tell verb, which has its own cap of 10).", + "minimum": 1, + "maximum": 1000, + "trusted": false, + "default": 100 } }, "resources": [ @@ -155,7 +163,7 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", - "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me"], + "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour"], "merge": "json", "template": "files/settings.json", "owner": "messenger"