pg_notify and the socket fan-out are not durable, so an offline phone used to miss a trigger forever and no-auto-stop would leave it ringing at nobody. Write one ledger row per enabled subscription in the trigger transaction, replay what a subscription still owes on every socket connect, and clear a row only on an explicit ack (live socket ack or ackAlarm); alarmStatus is a read-only check-in that answers 409 ERR_ALARM_TRIGGERED_OFFLINE with the missed frame. Ack is idempotent, and a destroyed alert cascades its debt away so a client is never told it missed an alarm that no longer exists.
77 lines
2.6 KiB
Go
77 lines
2.6 KiB
Go
// Copyright (C) 2026 Dcntrlized
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published
|
|
// by the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package realtime
|
|
|
|
import (
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// TestBroadcastLatency measures the fan-out path on real sockets: from the
|
|
// instant Broadcast is called to the instant BOTH subscribers have decoded the
|
|
// frame. It excludes the HTTP action and the database insert that precede a
|
|
// trigger in production, so it is the floor for the end-to-end number.
|
|
//
|
|
// It logs p50/p95/max and only fails on a gross regression (p95 > 500ms) to
|
|
// stay useful without being flaky on a loaded machine.
|
|
func TestBroadcastLatency(t *testing.T) {
|
|
alertID := uuid.New()
|
|
_, wsURL, hub := startTestServer(t, alertID)
|
|
|
|
first := dial(t, wsURL)
|
|
second := dial(t, wsURL)
|
|
waitClients(t, hub, alertID, 2)
|
|
|
|
const iterations = 100
|
|
samples := make([]time.Duration, 0, iterations)
|
|
for i := 0; i < iterations; i++ {
|
|
eventID := uuid.NewString()
|
|
frame := AlarmFrame{
|
|
Type: "alarm",
|
|
AlertID: alertID.String(),
|
|
EventID: eventID,
|
|
Action: "trigger",
|
|
Message: "latency probe",
|
|
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
|
}
|
|
start := time.Now()
|
|
if delivered := hub.Broadcast(alertID, frame); delivered != 2 {
|
|
t.Fatalf("iteration %d: Broadcast delivered to %d clients, want 2", i, delivered)
|
|
}
|
|
for _, conn := range []*websocket.Conn{first, second} {
|
|
got := readAlarm(t, conn)
|
|
if got.EventID != eventID {
|
|
t.Fatalf("iteration %d: event id = %q, want %q", i, got.EventID, eventID)
|
|
}
|
|
}
|
|
samples = append(samples, time.Since(start))
|
|
}
|
|
|
|
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
|
p50 := samples[len(samples)*50/100]
|
|
p95 := samples[len(samples)*95/100]
|
|
max := samples[len(samples)-1]
|
|
t.Logf("broadcast -> 2 clients decoded: n=%d p50=%v p95=%v max=%v",
|
|
len(samples), p50, p95, max)
|
|
if p95 > 500*time.Millisecond {
|
|
t.Fatalf("p95 latency %v exceeds the 500ms budget", p95)
|
|
}
|
|
}
|