feat(controller): add flap inhibition for each controllers
Build / ubuntu-latest (push) Failing after 3m3s
Build / windows-latest (push) Failing after 11m25s

This commit is contained in:
NanamiAdmin committed 2026-10-12 17:17:55 +08:00
1 parent 0c581abc4c
commit 03e74f49c3
5 files changed
+478 -5

No files matched your search

+26 -3
View File
@@ -5,13 +5,14 @@ import (
"encoding/json"
"fmt"
"os"
"time"
)
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
// other config files this one is auto-generated and optional: a missing or
// empty file yields an empty map, so every node falls back to its default
// enableStatusNotify value (true).
// empty file yields an empty map, so every node falls back to its defaults —
// enableStatusNotify true and flap_tolerant 0 (no flap inhibition).
func LoadBotNodeConfig(configPath string) error {
cfg := BotNodeMembers{}
data, err := os.ReadFile(configPath)
@@ -44,6 +45,27 @@ func NodeStatusNotifyEnabled(uuid string) bool {
return *opts.EnableStatusNotify
}
// maxFlapTolerantSeconds bounds the flap-inhibition window so an absurd
// hand-written value cannot overflow time.Duration.
const maxFlapTolerantSeconds = 86400
// NodeFlapTolerant returns how long an Offline notification for uuid should be
// held in order to absorb a quick reconnect, per bot_node_config.json's
// flap_tolerant. It returns 0 (inhibition disabled) when the node has no entry,
// the value is zero or negative, or the config has not been loaded — in every
// case the caller falls back to notifying immediately.
func NodeFlapTolerant(uuid string) time.Duration {
opts, ok := BotNodes()[uuid]
if !ok || opts.FlapTolerant <= 0 {
return 0
}
seconds := opts.FlapTolerant
if seconds > maxFlapTolerantSeconds {
seconds = maxFlapTolerantSeconds
}
return time.Duration(seconds * float64(time.Second))
}
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
// and stores it as a global singleton.
func LoadGlobalConfig(configPath string) (*Config, error) {
@@ -168,7 +190,8 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
// which is a plain registry of the nodes Komari reports (read back at startup
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID
// Komari no longer reports on a given fetch is kept rather than deleted, and
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for
// any per-node option a user set by hand — enableStatusNotify or flap_tolerant
// — is left untouched. UUIDs seen for
// the first time are added as an empty object, i.e. no parameter is written, so
// they inherit the enableStatusNotify default (true). The resulting JSON has
// its object keys emitted in sorted order by encoding/json, keeping the file
+63
View File
@@ -0,0 +1,63 @@
package config
import (
"testing"
"time"
"nukumizu-backend/global"
)
// TestNodeFlapTolerant covers the flap-inhibition window accessor against the
// per-node options in bot_node_config.json, including the hand-written values
// the comment on BotNodeOptions.FlapTolerant promises to accept.
func TestNodeFlapTolerant(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"seconds": { "flap_tolerant": 30 },
"fractional": { "flap_tolerant": 2.5 },
"zero": { "flap_tolerant": 0 },
"negative": { "flap_tolerant": -5 },
"unset": { "enableStatusNotify": true },
"absurd": { "flap_tolerant": 1e9 },
"throughput": { "flap_tolerant": 8, "enableStatusNotify": false }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
cases := []struct {
name string
uuid string
want time.Duration
}{
{"whole seconds", "seconds", 30 * time.Second},
{"fractional seconds parse", "fractional", 2500 * time.Millisecond},
{"zero disables", "zero", 0},
{"negative disables", "negative", 0},
{"absent field disables", "unset", 0},
{"unknown node disables", "not-in-the-file", 0},
{"absurd value is clamped", "absurd", maxFlapTolerantSeconds * time.Second},
{"independent of enableStatusNotify", "throughput", 8 * time.Second},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := NodeFlapTolerant(tc.uuid); got != tc.want {
t.Errorf("NodeFlapTolerant(%q) = %v, want %v", tc.uuid, got, tc.want)
}
})
}
}
// TestNodeFlapTolerantWithoutConfig pins the nil-safe default: before any
// bot_node_config.json has been loaded — and for a file that is missing or
// empty — no node has a window, so notifications keep their previous
// notify-immediately behavior.
func TestNodeFlapTolerantWithoutConfig(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, ``)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
if got := NodeFlapTolerant("anything"); got != 0 {
t.Errorf("node flap tolerance from an empty file = %v, want 0", got)
}
}
+12 -1
View File
@@ -257,13 +257,24 @@ type BotNodeOptions struct {
// bool) lets an absent field be told apart from an explicit false, and
// omitempty keeps untouched nodes stored as {}.
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
// FlapTolerant is the number of seconds a node's Offline notification is
// held in the hope the node returns. A node that comes back online within
// this window produces no notification at all; one still offline when the
// window elapses reports Offline as usual. Zero (or an absent field)
// disables inhibition, which keeps the notify-immediately behavior. It is
// a float64 rather than an int so a hand-written fractional value such as
// 2.5 parses instead of failing the whole config load at startup, and it
// is not a pointer because 0 already means "off", unlike
// EnableStatusNotify whose default is true.
FlapTolerant float64 `json:"flap_tolerant,omitempty"`
}
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
// options.
type BotNodeMembers map[string]BotNodeOptions
// botNodeConfig mirrors bot_node_config.json, populated by LoadBotNodeConfig.
// NodeConfig mirrors bot_node_config.json, populated by LoadNodeConfig.
// The map is rebuilt rather than mutated on every load, so the pointer can be
// swapped atomically; read it through BotNodes.
var botNodeConfig atomic.Pointer[BotNodeMembers]