hub v0.134.0: a down box is judged on longer missed-backup lines (R-872); household outage mail at most weekly (R-873); backup_catchup_done allowed (R-871)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -758,6 +758,15 @@ var operatorOnlyEvents = map[string]bool{
|
||||
// Read-only: the register itself stays unexported so nothing can widen it at runtime.
|
||||
func IsOperatorOnly(eventType string) bool { return operatorOnlyEvents[eventType] }
|
||||
|
||||
// householdLivenessTypes are the "your server cannot be reached" family; householdLivenessQuiet is how long after
|
||||
// one reached the household the next is held back (R-873).
|
||||
var (
|
||||
householdLivenessTypes = []string{"node_stale", "node_down", "host_stale", "host_down"}
|
||||
householdLivenessWeekly = map[string]bool{"node_stale": true, "node_down": true, "host_stale": true, "host_down": true}
|
||||
)
|
||||
|
||||
const householdLivenessQuiet = 7 * 24 * time.Hour
|
||||
|
||||
func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) {
|
||||
// R-97c: operator-tier events stop here, BEFORE prefs are consulted — the point is that no
|
||||
// customer configuration can opt in. Logged rather than dropped, so the skip is visible in
|
||||
@@ -785,6 +794,22 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, m
|
||||
return
|
||||
}
|
||||
|
||||
// R-873 (v0.134.0): "your server cannot be reached" reaches the HOUSEHOLD at most once per 7 days. A box that
|
||||
// is switched off every night (Tester 2, a laptop — measured 2026-10-05) mailed the household every night and
|
||||
// "reachable again" every morning. The operator still gets every edge (processOperator, above), and the
|
||||
// recovery mail stays paired with a down mail the household actually received (processRecovery), so a skipped
|
||||
// down mail also skips its recovery. Persisted (notification_log), so a hub restart does not reset it.
|
||||
// Decided by CC — operator may reverse (`08` §3.1). Pinned by TestR873_*.
|
||||
if householdLivenessWeekly[eventType] {
|
||||
if last, ok, err := d.store.LastCustomerSentAt(customerID, householdLivenessTypes); err == nil && ok && time.Since(last) < householdLivenessQuiet {
|
||||
d.store.LogNotification(customerID, eventType, severity, message, "skipped",
|
||||
"liveness: the household hears this at most once per 7 days (R-873)", "customer")
|
||||
d.logger.Printf("[INFO] Customer mail skipped for %s/%s — the household was told about an outage %s ago (R-873: at most once per 7 days)",
|
||||
customerID, eventType, time.Since(last).Round(time.Minute))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Customer cooldown (from prefs, default 6h)
|
||||
cooldownHours := prefs.CooldownHours
|
||||
if cooldownHours <= 0 {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-873 (v0.134.0) — a household whose box is OFF EVERY NIGHT (Tester 2, a laptop) is told "your server cannot be
|
||||
// reached" at most once per 7 days; the operator still hears every edge; the recovery mail stays paired.
|
||||
// COMPANION RED-PROOF: drop the householdLivenessWeekly block in processCustomer → night 2 mails the household.
|
||||
func TestR873_HouseholdHearsAnOutageAtMostWeekly(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "d.db")
|
||||
st, err := store.New(path, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p"})
|
||||
if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"node_down"}, 6); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
night := func() (cust, op int) {
|
||||
// a fresh dispatcher each night: the in-memory 6 h cooldown is gone by the next evening anyway
|
||||
d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
|
||||
sent := captureSeam(d)
|
||||
d.ProcessEvent("c1", "node_down", "error", "No report received for 90m", "{}", "hub")
|
||||
d.ProcessEvent("c1", "node_recovered", "info", "Reports resumed", "{}", "hub")
|
||||
return len(mailsFor(*sent, "cust@example.com")), len(mailsFor(*sent, "op@felhom.eu"))
|
||||
}
|
||||
if c, o := night(); c != 2 || o != 2 {
|
||||
t.Fatalf("night 1: household %d mails, operator %d — want down+recovered to both", c, o)
|
||||
}
|
||||
if c, o := night(); c != 0 || o != 2 {
|
||||
t.Fatalf("night 2: household %d mails (want 0 — told once this week), operator %d (want every edge)", c, o)
|
||||
}
|
||||
// Eight days later the household is told again.
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
old := time.Now().UTC().Add(-8 * 24 * time.Hour).Format("2006-01-02 15:04:05")
|
||||
if _, err := db.Exec(`UPDATE notification_log SET created_at = ?`, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, _ := night(); c != 2 {
|
||||
t.Fatalf("after 8 days the household got %d mails, want down+recovered again", c)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user