v0.342.0 (unreleased): the upload and CPU watch — an app that sends or burns far more than its own numbers explain is one operator-only alarm; the line learnt from the off-site push; qBittorrent's default upload limit (never over the household's)
gates / gates (push) Successful in 1m17s
gates / gates (push) Successful in 1m17s
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s, 25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex, app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs. Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The upload watch's baseline: per-minute sums across the app's containers, p95, only the named containers.
|
||||
// RED-PROOF: take the p95 per container instead of the per-minute SUM → 2 + 1 = 3 reads as 2 → fails.
|
||||
func TestStackTxP95Mbps_SumsContainersPerMinute(t *testing.T) {
|
||||
st, err := NewMetricsStore(filepath.Join(t.TempDir(), "m.db"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC).Unix()
|
||||
var a, b, other int64
|
||||
for i := int64(0); i < 200; i++ {
|
||||
ts := t0 + i*60
|
||||
st.InsertContainerMetrics([]ContainerSample{
|
||||
{Timestamp: ts, ContainerName: "nextcloud", NetTxBytes: a},
|
||||
{Timestamp: ts, ContainerName: "nextcloud-db", NetTxBytes: b},
|
||||
{Timestamp: ts, ContainerName: "qbittorrent", NetTxBytes: other},
|
||||
})
|
||||
a += 2e6 / 8 * 60 // 2 Mbit/s
|
||||
b += 1e6 / 8 * 60 // 1 Mbit/s
|
||||
other += 500e6 / 8 * 60
|
||||
}
|
||||
p95, minutes, err := st.StackTxP95Mbps([]string{"nextcloud", "nextcloud-db"}, time.Unix(t0, 0))
|
||||
if err != nil || minutes != 199 || p95 < 2.99 || p95 > 3.01 {
|
||||
t.Fatalf("p95 = %v over %d minutes, err %v — want 3 Mbit/s over 199", p95, minutes, err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -357,3 +359,56 @@ func (s *MetricsStore) HourSampleCounts(from, to time.Time) (map[int64]int, erro
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// StackTxP95Mbps is the 95th percentile of an app's upload, its containers summed per minute, over the rows since
|
||||
// `since` (the upload watch's „far above its own p95" rule for an app the box cannot ask; internal/upwatch). minutes
|
||||
// is how many one-minute buckets the history holds. The counters are `docker stats`' text values (three significant
|
||||
// digits), coarse but enough for a p95 the rule multiplies by ten.
|
||||
func (s *MetricsStore) StackTxP95Mbps(containers []string, since time.Time) (p95 float64, minutes int, err error) {
|
||||
if len(containers) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
q := "SELECT container_name, ts, net_tx_bytes FROM container_metrics WHERE ts >= ? AND container_name IN (?" +
|
||||
strings.Repeat(",?", len(containers)-1) + ") ORDER BY container_name, ts"
|
||||
args := []interface{}{since.Unix()}
|
||||
for _, c := range containers {
|
||||
args = append(args, c)
|
||||
}
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
buckets := map[int64]float64{}
|
||||
var prevName string
|
||||
var prevTS, prevTx int64
|
||||
havePrev := false
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var ts int64
|
||||
var tx sql.NullInt64
|
||||
if err := rows.Scan(&name, &ts, &tx); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if !tx.Valid {
|
||||
havePrev = false
|
||||
continue
|
||||
}
|
||||
if havePrev && name == prevName && ts > prevTS && ts-prevTS <= 180 && tx.Int64 >= prevTx {
|
||||
buckets[ts/60] += float64(tx.Int64-prevTx) * 8 / float64(ts-prevTS) / 1e6
|
||||
}
|
||||
prevName, prevTS, prevTx, havePrev = name, ts, tx.Int64, true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(buckets) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
vals := make([]float64, 0, len(buckets))
|
||||
for _, v := range buckets {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
return vals[int(float64(len(vals)-1)*0.95)], len(vals), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user