[feat](alarm): add a durable per-device delivery ledger

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.
This commit is contained in:
2026-10-19 02:26:46 +01:00
parent 0334d7fe93
commit 6e6a1e9c8a
12 changed files with 1128 additions and 77 deletions
@@ -0,0 +1,52 @@
\c dcntrlized;
-- 005_alarm_deliveries.sql — a durable per-device delivery ledger.
--
-- Why this exists: pg_notify and the WebSocket fan-out are not durable. A phone
-- that is offline at trigger time misses the frame forever, and the in-memory
-- catch-up cache only lives in the process that produced the event. This table is
-- the durable record of who still owes an alarm, so "no auto-stop" is safe: the
-- alarm stays live until the owner disarms it, and every subscribed device has a
-- row here until it has been told.
--
-- Shape: one row per (alert, subscription, event), written in the SAME
-- transaction as the alarm_events insert and therefore before the NOTIFY on
-- commit. If this insert fails the trigger fails — a fan-out that records no debt
-- would be exactly the silent failure this design prevents.
--
-- Cascades are in the schema, not in application code: deleting an alert or a
-- subscription removes its ledger rows. event_id deliberately has no foreign key:
-- the ledger is the record of what a device owes, and it must outlive nothing but
-- its alert/subscription (both of which cascade).
--
-- Idempotent (same guard pattern as 002/003/004): a re-apply of the whole
-- sequence is a clean no-op.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
RETURN;
END IF;
CREATE TABLE IF NOT EXISTS alarm.alarm_deliveries (
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
subscription_id UUID NOT NULL REFERENCES alarm.subscriptions(id) ON DELETE CASCADE,
event_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
acked_at TIMESTAMPTZ,
PRIMARY KEY (alert_id, subscription_id, event_id)
);
-- The check-in reads by alert + subscription; the pending index keeps
-- "who still owes this alert" a small partial scan instead of a table scan.
CREATE INDEX IF NOT EXISTS alarm_deliveries_alert_idx
ON alarm.alarm_deliveries (alert_id);
CREATE INDEX IF NOT EXISTS alarm_deliveries_pending_idx
ON alarm.alarm_deliveries (alert_id, subscription_id)
WHERE acked_at IS NULL;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON alarm.alarm_deliveries TO alarm_user;
END IF;
END
$$;
+136
View File
@@ -0,0 +1,136 @@
// 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 actions
import (
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid"
"github.com/google/uuid"
)
// ackAlarmRequest is the HTTP dismissal of a ringing alarm. The device rides the
// payload (or the query string) exactly like joinAlert's, so the caller is
// addressed by (alert, user, device) and can only clear its own debt.
type ackAlarmRequest struct {
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
DeviceID string `json:"deviceId"`
}
type ackAlarmResponse struct {
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
Acked int64 `json:"acked"`
Destroyed bool `json:"destroyed"`
}
// AckAlarm implements POST /action/ackAlarm.
//
// User ruling: "On client dismiss of a triggered alarm, a request is sent back to
// the server ACKing the alarm, that's what triggers the clear." Clearing is
// therefore ack-driven and never response-driven: the alarmStatus check-in is
// read-only, and this action (or the WebSocket `ack` frame) is what retires the
// delivery-ledger row.
//
// Idempotent: the second ack for the same event clears nothing, returns
// acked:0, and is not an error. When the ack clears the alert's last unacked
// row, the alert is destroyed (reading A) and `destroyed:true` reports it — the
// alert no longer exists, which is the intended outcome, not a failure.
func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
const action = "ackAlarm"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
eventRaw := fieldOrQuery(r, payload, "eventId")
if eventRaw == "" {
validationError(w, "eventId is required")
return
}
eventID, err := uuid.Parse(eventRaw)
if err != nil {
validationError(w, "eventId is not a valid uuid")
return
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()}
// The owner has no delivery row (only subscribers are owed alarms), so an
// owner ack is a well-formed no-op rather than a validation error.
if alert.AdminUserID != p.ID {
raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId"))
if err != nil {
validationError(w, "%v", err)
return
}
cleared, err := db.AckAlarmDelivery(r.Context(), alertID, p.ID, deviceid.Hash(raw), eventID)
if err != nil {
dbError(w, action, err)
return
}
resp.Acked = cleared
if cleared > 0 {
if err := db.RecordDelivered(r.Context(), alertID, p.ID); err != nil {
dbError(w, action, err)
return
}
destroyed, err := db.CompleteAlertDeliveries(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
resp.Destroyed = destroyed
}
}
Debug(action, "POST", "alert=%s event=%s acked=%d destroyed=%t", alertID, eventID, resp.Acked, resp.Destroyed)
writeJSON(w, http.StatusOK, resp)
}
// fieldOrQuery reads a string from the payload and falls back to the query
// string, matching how the other actions accept both transports.
func fieldOrQuery(r *http.Request, payload any, field string) string {
if v := strings.TrimSpace(r.URL.Query().Get(field)); v != "" {
return v
}
if payload == nil {
return ""
}
return strings.TrimSpace(lenientString(payload, field))
}
+50
View File
@@ -0,0 +1,50 @@
// 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 actions
import (
"context"
"errors"
"github.com/google/uuid"
)
// completeAlertDelivery is the decision point for the second half of the
// delivery design: "once every client is removed for an alarm, the alarm is then
// deleted with whichever cascade deletions are relevant".
//
// It is DELIBERATELY UNWIRED — nothing calls it. That sentence has two readings
// and they are different products, so the user has been asked which one is meant:
//
// (a) delete the ALERT. The definition, both tokens, the subscriptions and the
// event history go with it (ON DELETE CASCADE). An alert becomes a
// one-shot event: once every device has acknowledged, it cannot be
// triggered again and the admin token becomes dead.
// (b) retire only the DELIVERY record (and/or the event). The alert survives,
// stays armed, and can be triggered again later; only the ledger bookkeeping
// about this alarm is completed.
//
// Implementing either now would delete or preserve data on a reading nobody
// confirmed, so this function reports that the decision is pending. When the
// answer arrives it gets implemented in exactly one place and called from the
// paths that observe the last outstanding delivery (the alarmStatus check-in and
// the WebSocket ack), where "every client has been told" is decided atomically
// from the ledger rather than from a client's claim.
func completeAlertDelivery(ctx context.Context, alertID uuid.UUID) error {
_ = ctx
_ = alertID
return errors.New("completeAlertDelivery: pending user decision between (a) deleting the alert as a one-shot and (b) retiring only the delivery record")
}
+95
View File
@@ -0,0 +1,95 @@
// 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 actions
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"github.com/google/uuid"
)
// TestWriteOfflineAlarmShape pins the 409 check-in contract: the status, the
// code, and that the body carries a real alarm frame with the SAME event id the
// device missed. This is the one response an integrator must not treat as an
// error, so its shape is not allowed to drift silently.
func TestWriteOfflineAlarmShape(t *testing.T) {
alertID, eventID := uuid.New(), uuid.New()
override := "Leave the building now"
alert := &db.Alert{ID: alertID, Message: &override}
at := time.Date(2026, 10, 18, 17, 5, 0, 123000000, time.UTC)
pending := &db.PendingDelivery{EventID: eventID, Action: "trigger", Message: &override, ServerTime: at}
rec := httptest.NewRecorder()
writeOfflineAlarm(rec, alert, pending)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Alarm struct {
Type string `json:"type"`
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
} `json:"alarm"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Error.Code != apierr.AlarmTriggeredOffline {
t.Fatalf("error.code = %q, want %q", body.Error.Code, apierr.AlarmTriggeredOffline)
}
if body.Error.Message == "" {
t.Fatal("error.message must not be empty")
}
if body.Alarm.Type != "alarm" || body.Alarm.Action != "trigger" {
t.Fatalf("alarm frame = %+v, want type=alarm action=trigger", body.Alarm)
}
if body.Alarm.EventID != eventID.String() || body.Alarm.AlertID != alertID.String() {
t.Fatalf("alarm ids = %q/%q, want %q/%q", body.Alarm.AlertID, body.Alarm.EventID, alertID, eventID)
}
if body.Alarm.Message != override {
t.Fatalf("alarm.message = %q, want %q", body.Alarm.Message, override)
}
if body.Alarm.ServerTime != "2026-10-18T17:05:00.123Z" {
t.Fatalf("alarm.serverTime = %q", body.Alarm.ServerTime)
}
}
// TestCompleteAlertDeliveryIsUnwired guards the deliberate hole: the function
// must keep saying "decision pending" rather than quietly deleting or preserving
// data on one reading of the user's sentence.
func TestCompleteAlertDeliveryIsUnwired(t *testing.T) {
if err := completeAlertDelivery(nil, uuid.New()); err == nil {
t.Fatal("completeAlertDelivery must report the pending decision, not succeed")
}
}
+168
View File
@@ -0,0 +1,168 @@
// 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 db
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// PendingDelivery is an unacked delivery together with the event it points at.
// It is the payload of the "you missed an alarm" check-in.
type PendingDelivery struct {
EventID uuid.UUID
Action string
Message *string
ServerTime time.Time
}
// PendingTrigger is the READ-ONLY offline check-in: it reports the newest
// trigger this device still owes, or ErrNotFound when it owes nothing. It marks
// NO row — clearing is ack-driven (see AckAlarmDelivery / AckDelivery), because a
// check-in answer can be lost in transit and must not forfeit the alarm.
//
// deviceID selects the subscription the same way the socket does: the exact
// stored digest when given, otherwise the caller's oldest subscription on the
// alert. deviceID is expected to be ALREADY HASHED (actions hash the raw query
// value before calling); passing a raw id would simply match nothing.
func PendingTrigger(ctx context.Context, alertID, userID uuid.UUID, deviceID string) (*PendingDelivery, error) {
const q = `
WITH target AS (
SELECT s.id
FROM alarm.subscriptions s
WHERE s.alert_id = $1
AND s.subscriber_user_id = $2
AND ($3 = '' OR s.device_id = $3)
ORDER BY s.created, s.id
LIMIT 1
)
SELECT e.id, e.kind::text, e.message, e.created_at
FROM alarm.alarm_deliveries d
JOIN alarm.alarm_events e ON e.id = d.event_id
WHERE d.alert_id = $1
AND d.subscription_id = (SELECT id FROM target)
AND d.acked_at IS NULL
ORDER BY e.created_at DESC, e.id DESC
LIMIT 1`
var out PendingDelivery
err := Pool().QueryRow(ctx, q, alertID, userID, deviceID).Scan(
&out.EventID, &out.Action, &out.Message, &out.ServerTime)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &out, nil
}
// PendingReplay returns the trigger frames a subscription still owes, oldest
// first — the WebSocket reconnect path. It marks nothing: a replay is not a
// delivery, and the debt only clears when the client acks (or checks in), so a
// frame the client never processed stays owed.
//
// A trigger that has since been disarmed is not replayed: ringing a stale alarm
// on reconnect would be worse than staying silent, and the check-in clears that
// debt instead (see ClaimPendingDelivery). The NOT EXISTS keeps this one indexed
// anti-join rather than a scan of the event history.
//
// The LIMIT bounds the work a pathological ledger can put on a handshake; more
// than a handful of owed events for one device means something is very wrong.
func PendingReplay(ctx context.Context, alertID, subscriptionID uuid.UUID) ([]PendingDelivery, error) {
const q = `
SELECT e.id, e.kind::text, e.message, e.created_at
FROM alarm.alarm_deliveries d
JOIN alarm.alarm_events e ON e.id = d.event_id
WHERE d.alert_id = $1
AND d.subscription_id = $2
AND d.acked_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM alarm.alarm_events x
WHERE x.alert_id = d.alert_id
AND x.kind = 'disarm'
AND x.created_at > e.created_at
)
ORDER BY e.created_at, e.id
LIMIT 20`
rows, err := Pool().Query(ctx, q, alertID, subscriptionID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PendingDelivery, 0, 2)
for rows.Next() {
var d PendingDelivery
if err := rows.Scan(&d.EventID, &d.Action, &d.Message, &d.ServerTime); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// AckDelivery marks one delivery acknowledged (acked_at = now()) and reports how
// many rows it cleared. It is the WebSocket parity path: a client that received
// and acked the live frame never also gets the offline check-in. Idempotent by
// construction — a repeated ack (or an ack for an event the device never owed)
// updates nothing and is not an error; the zero count is what keeps analytics
// honest and is why callers only fire the delivered row when n > 0.
func AckDelivery(ctx context.Context, alertID, subscriptionID, eventID uuid.UUID) (int64, error) {
tag, err := Pool().Exec(ctx,
`UPDATE alarm.alarm_deliveries
SET acked_at = now()
WHERE alert_id = $1 AND subscription_id = $2 AND event_id = $3
AND acked_at IS NULL`,
alertID, subscriptionID, eventID)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// AckAlarmDelivery clears the caller's subscription rows for one event and
// reports how many were cleared — the explicit `ackAlarm` action, sent when the
// person holding the phone dismisses a ringing alarm.
//
// deviceID is the ALREADY HASHED digest of the raw client value; the actor is
// addressed through (alert, user, device) exactly like the socket, so a caller can
// only ever clear its own device's debt. Idempotent: a repeat ack matches no row
// and returns 0, never an error.
func AckAlarmDelivery(ctx context.Context, alertID, userID uuid.UUID, deviceID string, eventID uuid.UUID) (int64, error) {
const q = `
UPDATE alarm.alarm_deliveries d
SET acked_at = now()
WHERE d.alert_id = $1
AND d.event_id = $2
AND d.acked_at IS NULL
AND d.subscription_id IN (
SELECT s.id FROM alarm.subscriptions s
WHERE s.alert_id = $1
AND s.subscriber_user_id = $3
AND s.device_id = $4
)`
tag, err := Pool().Exec(ctx, q, alertID, eventID, userID, deviceID)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
+287
View File
@@ -0,0 +1,287 @@
// 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 db
// The lifecycle probe. It is skipped unless DATABASE_URL points at a database
// with migrations 001–008 applied; the default suite stays DB-free. It covers the
// four ways an alert can end plus the ledger and log-table mechanics that only
// SQL can prove:
//
// - drain-to-empty (every subscriber acked) destroys the alert and writes
// `destroyed/delivered` in one transaction;
// - a trigger with zero subscribers does NOT destroy (guard 1);
// - expiry destroys with unacked rows and the alert reads as not-found;
// - disarm of a triggered alert destroys with reason `disarmed`;
// - acks are idempotent; the second ack clears nothing;
// - event-log retention pruning works.
//
// Run it with:
//
// DATABASE_URL='postgres://postgres:[email protected]:55432/dcntrlized?sslmode=disable' \
// go test -run TestLedgerProbe -count=1 -v ./db/
//
// It uses its own freshly-created alerts and deletes them at the end.
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"github.com/google/uuid"
)
func TestLedgerProbe(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set; lifecycle probe needs a migrated database")
}
ctx := context.Background()
_ = dsn
NewDB()
t.Cleanup(Close)
admin := uuid.New()
sub1 := uuid.New()
sub2 := uuid.New()
sub3 := uuid.New()
newAlert := func(name string, ttl time.Duration) *Alert {
msg := "probe"
a, err := CreateAlert(ctx, &Alert{
AdminUserID: admin,
Name: name,
Message: &msg,
AdminTokenHash: []byte("probe"),
SubscribeToken: "probe-" + uuid.NewString(),
ExpiresAt: time.Now().UTC().Add(ttl),
})
if err != nil {
t.Fatalf("CreateAlert: %v", err)
}
return a
}
join := func(alertID, user uuid.UUID) *Subscription {
s, err := CreateSubscription(ctx, &Subscription{
AlertID: alertID, SubscriberUserID: user, DeviceID: "sha256:" + strings.Repeat("a", 64),
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
return s
}
countAnalytics := func(alertID uuid.UUID, event, reason string) int {
var n int
err := Pool().QueryRow(ctx,
`SELECT count(*) FROM alarm.event_log WHERE alert_hash = $1 AND event = $2 AND coalesce(reason,'') = $3`,
HashUUID(alertID), event, reason).Scan(&n)
if err != nil {
t.Fatalf("analytics count: %v", err)
}
return n
}
// ---- drain-to-empty destroys the alert with reason "delivered" ----
drain := newAlert("probe drain", time.Hour)
join(drain.ID, sub1)
join(drain.ID, sub2)
join(drain.ID, sub3) // third enabled subscriber: acking a different device's row must not count
disabled, err := CreateSubscription(ctx, &Subscription{
AlertID: drain.ID, SubscriberUserID: uuid.New(), DeviceID: "sha256:" + strings.Repeat("b", 64),
})
if err != nil {
t.Fatalf("disabled subscription: %v", err)
}
if _, err := Pool().Exec(ctx, `UPDATE alarm.subscriptions SET enabled = false WHERE id = $1`, disabled.ID); err != nil {
t.Fatalf("disable sub: %v", err)
}
_, ev, err := TriggerAlert(ctx, drain.ID, admin, nil)
if err != nil {
t.Fatalf("TriggerAlert: %v", err)
}
var rows, enabled int
if err := Pool().QueryRow(ctx,
`SELECT count(*) FROM alarm.alarm_deliveries WHERE alert_id = $1`, drain.ID).Scan(&rows); err != nil {
t.Fatalf("ledger rows: %v", err)
}
if err := Pool().QueryRow(ctx,
`SELECT count(*) FROM alarm.subscriptions WHERE alert_id = $1 AND enabled`, drain.ID).Scan(&enabled); err != nil {
t.Fatalf("enabled count: %v", err)
}
if rows != 3 || enabled != 3 {
t.Fatalf("trigger produced %d ledger rows for %d enabled subscriptions, want 3/3", rows, enabled)
}
if got := countAnalytics(drain.ID, EventTriggered, ""); got != 1 {
t.Fatalf("triggered analytics rows = %d, want 1", got)
}
pending, err := PendingTrigger(ctx, drain.ID, sub1, "")
if err != nil || pending.EventID != ev.ID {
t.Fatalf("PendingTrigger = %+v, %v; want event %s", pending, err, ev.ID)
}
// Ack all three, idempotently. Only the last one completes the drain.
for i, user := range []uuid.UUID{sub1, sub2, sub3} {
sub, err := deviceDigest(ctx, drain.ID, user)
if err != nil {
t.Fatalf("digest %d: %v", i, err)
}
n, err := AckAlarmDelivery(ctx, drain.ID, user, sub, ev.ID)
if err != nil {
t.Fatalf("AckAlarmDelivery %d: %v", i, err)
}
if n != 1 {
t.Fatalf("ack %d cleared %d rows, want 1", i, n)
}
if again, err := AckAlarmDelivery(ctx, drain.ID, user, sub, ev.ID); err != nil || again != 0 {
t.Fatalf("repeat ack %d = %d, %v; want 0, nil", i, again, err)
}
}
destroyed, err := CompleteAlertDeliveries(ctx, drain.ID)
if err != nil {
t.Fatalf("CompleteAlertDeliveries: %v", err)
}
if !destroyed {
t.Fatal("drained alert was not destroyed")
}
if _, err := GetAlert(ctx, drain.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("drained alert read = %v, want ErrNotFound", err)
}
if got := countAnalytics(drain.ID, EventDestroyed, DestroyReasonDelivered); got != 1 {
t.Fatalf("destroyed/delivered analytics rows = %d, want 1", got)
}
// ---- guard 1: a zero-subscriber trigger must NOT destroy ----
empty := newAlert("probe empty", time.Hour)
if _, _, err := TriggerAlert(ctx, empty.ID, admin, nil); err != nil {
t.Fatalf("TriggerAlert(empty): %v", err)
}
ok, err := CompleteAlertDeliveries(ctx, empty.ID)
if err != nil {
t.Fatalf("CompleteAlertDeliveries(empty): %v", err)
}
if ok {
t.Fatal("zero-subscriber trigger destroyed the owner's alert")
}
if _, err := GetAlert(ctx, empty.ID); err != nil {
t.Fatalf("zero-subscriber alert should still exist: %v", err)
}
// ---- expiry destroys with unacked rows, and reads 404 before the sweeper ----
expiring := newAlert("probe expire", time.Hour)
join(expiring.ID, sub1)
if _, _, err := TriggerAlert(ctx, expiring.ID, admin, nil); err != nil {
t.Fatalf("TriggerAlert(expiring): %v", err)
}
if _, err := Pool().Exec(ctx, `UPDATE alarm.alerts SET expires_at = now() - interval '1 second' WHERE id = $1`, expiring.ID); err != nil {
t.Fatalf("force expiry: %v", err)
}
if _, err := GetAlert(ctx, expiring.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expired alert read = %v, want ErrNotFound", err)
}
n, err := ExpireAlerts(ctx, 100)
if err != nil || n < 1 {
t.Fatalf("ExpireAlerts = %d, %v; want >= 1", n, err)
}
if got := countAnalytics(expiring.ID, EventDestroyed, DestroyReasonExpired); got != 1 {
t.Fatalf("destroyed/expired analytics rows = %d, want 1", got)
}
var leftovers int
if err := Pool().QueryRow(ctx,
`SELECT count(*) FROM alarm.alarm_deliveries WHERE alert_id = $1`, expiring.ID).Scan(&leftovers); err != nil {
t.Fatalf("leftover rows: %v", err)
}
if leftovers != 0 {
t.Fatalf("expiry left %d delivery rows, want 0 (cascade)", leftovers)
}
// ---- disarm of a triggered alert ends it ----
disarmed := newAlert("probe disarm", time.Hour)
join(disarmed.ID, sub1)
if _, _, err := TriggerAlert(ctx, disarmed.ID, admin, nil); err != nil {
t.Fatalf("TriggerAlert(disarm): %v", err)
}
if _, _, err := DisarmAlert(ctx, disarmed.ID, admin, nil); err != nil {
t.Fatalf("DisarmAlert: %v", err)
}
if _, err := GetAlert(ctx, disarmed.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("disarmed alert read = %v, want ErrNotFound", err)
}
if got := countAnalytics(disarmed.ID, EventDestroyed, DestroyReasonDisarmed); got != 1 {
t.Fatalf("destroyed/disarmed analytics rows = %d, want 1", got)
}
// ---- deletion wins over debt: cascade removes the ledger, no replay/409 ----
deleted := newAlert("probe delete", time.Hour)
join(deleted.ID, sub1)
if _, _, err := TriggerAlert(ctx, deleted.ID, admin, nil); err != nil {
t.Fatalf("TriggerAlert(deleted): %v", err)
}
if err := DeleteAlert(ctx, deleted.ID); err != nil {
t.Fatalf("DeleteAlert: %v", err)
}
if _, err := PendingTrigger(ctx, deleted.ID, sub1, ""); !errors.Is(err, ErrNotFound) {
t.Fatalf("deleted alert still owes a delivery: %v", err)
}
if _, err := GetAlert(ctx, deleted.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("deleted alert read = %v, want ErrNotFound", err)
}
if got := countAnalytics(deleted.ID, EventDestroyed, DestroyReasonDeleted); got != 1 {
t.Fatalf("destroyed/deleted event rows = %d, want 1", got)
}
// ---- disarm of an ARMED alert also ends it (delete-without-trigger) ----
armed := newAlert("probe armed", time.Hour)
if _, _, err := DisarmAlert(ctx, armed.ID, admin, nil); err != nil {
t.Fatalf("DisarmAlert(armed): %v", err)
}
if _, err := GetAlert(ctx, armed.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("armed disarm left the alert alive: %v", err)
}
if got := countAnalytics(armed.ID, EventDestroyed, DestroyReasonDisarmed); got != 1 {
t.Fatalf("armed disarm event rows = %d, want 1", got)
}
// ---- event-log retention: age-based delete ----
if _, err := Pool().Exec(ctx,
`INSERT INTO alarm.event_log (occurred_at, event, alert_hash) VALUES (now() - interval '30 days', 'created', $1)`,
HashUUID(uuid.New())); err != nil {
t.Fatalf("insert old event: %v", err)
}
if n, err := PruneEventLog(ctx, EventLogRetention); err != nil || n < 1 {
t.Fatalf("PruneEventLog = %d, %v; want >= 1", n, err)
}
// Clean any leftover probe alerts (drain/empty) so repeat runs are clean.
_, _ = Pool().Exec(ctx, `DELETE FROM alarm.alerts WHERE name LIKE 'probe %'`)
t.Log("lifecycle probe ok: 3/3 ledger rows, idempotent acks, drain/zero-sub/expiry/disarm/deletion endings, event-log retention")
}
// deviceDigest reads back the stored (already hashed) device id of a subscription.
func deviceDigest(ctx context.Context, alertID, userID uuid.UUID) (string, error) {
var d string
err := Pool().QueryRow(ctx,
`SELECT device_id FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2
ORDER BY created, id LIMIT 1`, alertID, userID).Scan(&d)
return d, err
}
var _ = alertstate.KindTrigger
+52 -21
View File
@@ -1,3 +1,18 @@
// 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 actions package actions
import ( import (
@@ -5,9 +20,35 @@ import (
"net/http" "net/http"
"git.dcntrlized.org/Dcntrlized/Alarm/actions" "git.dcntrlized.org/Dcntrlized/Alarm/actions"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"git.dcntrlized.org/Dcntrlized/Alarm/types" "git.dcntrlized.org/Dcntrlized/Alarm/types"
) )
// This layer routes /action/{actionName} by HTTP method. It sits in front of the
// action handlers, so its errors are plain text (apierr.WriteText) rather than
// the JSON envelope the handlers emit; API.md documents that boundary. Both
// emitters guarantee a non-empty message.
const notImplementedMessage = "Action not implemented"
// notImplemented answers an action/verb pair the dispatcher does not route.
func notImplemented(w http.ResponseWriter) {
apierr.WriteText(w, http.StatusNotImplemented, notImplementedMessage)
}
// decodeEnvelope reads the {payload} wrapper. Unknown top-level fields are
// rejected (the inner payload is decoded leniently by the action itself).
func decodeEnvelope(w http.ResponseWriter, r *http.Request) (types.ActionRequest, bool) {
var req types.ActionRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
apierr.WriteText(w, http.StatusBadRequest, err.Error())
return req, false
}
return req, true
}
// ActionActionNameGet dispatches the GET /action/{actionName} request. // ActionActionNameGet dispatches the GET /action/{actionName} request.
// //
// GET carries no body: the read actions take their arguments from the query // GET carries no body: the read actions take their arguments from the query
@@ -30,7 +71,7 @@ func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[stri
case "me": case "me":
actions.Me(w, r) actions.Me(w, r)
default: default:
http.Error(w, "Action not implemented", http.StatusNotImplemented) notImplemented(w)
return return
} }
} }
@@ -38,11 +79,8 @@ func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[stri
// ActionActionNamePost dispatches the POST /action/{actionName} request. // ActionActionNamePost dispatches the POST /action/{actionName} request.
func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) { func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"] action := params["actionName"]
var req types.ActionRequest req, ok := decodeEnvelope(w, r)
dec := json.NewDecoder(r.Body) if !ok {
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -64,12 +102,12 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
actions.DisarmAlert(w, r, req.Payload) actions.DisarmAlert(w, r, req.Payload)
case "alarmStatus": case "alarmStatus":
actions.AlarmStatus(w, r, req.Payload) actions.AlarmStatus(w, r, req.Payload)
case "ackAlarm":
actions.AckAlarm(w, r, req.Payload)
case "deleteAlert": case "deleteAlert":
actions.DeleteAlert(w, r, req.Payload) actions.DeleteAlert(w, r, req.Payload)
case "qrImage": case "qrImage":
actions.QRImage(w, r, req.Payload) actions.QRImage(w, r, req.Payload)
case "rotateSubscribeToken":
actions.RotateSubscribeToken(w, r, req.Payload)
// Shared-auth proxies. // Shared-auth proxies.
case "login": case "login":
@@ -83,7 +121,7 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
case "logout": case "logout":
actions.Logout(w, r, req.Payload) actions.Logout(w, r, req.Payload)
default: default:
http.Error(w, "Action not implemented", http.StatusNotImplemented) notImplemented(w)
return return
} }
} }
@@ -91,17 +129,13 @@ func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[str
// ActionActionNamePut dispatches the PUT /action/{actionName} request. // ActionActionNamePut dispatches the PUT /action/{actionName} request.
func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) { func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"] action := params["actionName"]
var req types.ActionRequest if _, ok := decodeEnvelope(w, r); !ok {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
switch action { switch action {
default: default:
http.Error(w, "Action not implemented", http.StatusNotImplemented) notImplemented(w)
return return
} }
} }
@@ -111,11 +145,8 @@ func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[stri
// verb and carries the same {payload} envelope. // verb and carries the same {payload} envelope.
func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) { func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) {
action := params["actionName"] action := params["actionName"]
var req types.ActionRequest req, ok := decodeEnvelope(w, r)
dec := json.NewDecoder(r.Body) if !ok {
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -125,7 +156,7 @@ func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[s
case "removeSubscription": case "removeSubscription":
actions.RemoveSubscription(w, r, req.Payload) actions.RemoveSubscription(w, r, req.Payload)
default: default:
http.Error(w, "Action not implemented", http.StatusNotImplemented) notImplemented(w)
return return
} }
} }
+41
View File
@@ -16,10 +16,13 @@
package realtime package realtime
import ( import (
"context"
"encoding/json" "encoding/json"
"sync" "sync"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -107,6 +110,11 @@ func (c *client) readPump() {
c.acks++ c.acks++
c.ackMu.Unlock() c.ackMu.Unlock()
c.hub.recordAck(f.EventID) c.hub.recordAck(f.EventID)
// Parity with the offline check-in: the live frame was received and
// acknowledged, so this device no longer owes it. Without this an
// online client would also be told 409 ERR_ALARM_TRIGGERED_OFFLINE
// later — i.e. told twice about one alarm.
c.ackDelivery(f.EventID)
case "ping": case "ping":
c.touch() c.touch()
// Answer at the protocol level: the frozen contract defines no // Answer at the protocol level: the frozen contract defines no
@@ -116,6 +124,39 @@ func (c *client) readPump() {
} }
} }
// ackDelivery marks the ledger row for a client ack. It is best-effort and
// idempotent: a malformed event id, an admin connection (no subscription) or a
// repeated ack are all silent no-ops. A database failure is logged, never fatal —
// the client already has the alarm, and the worst case is one duplicate offline
// signal, not a dropped ring.
func (c *client) ackDelivery(eventID string) {
if c.identity.SubscriptionID == nil {
return
}
id, err := uuid.Parse(eventID)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cleared, err := db.AckDelivery(ctx, c.identity.AlertID, *c.identity.SubscriptionID, id)
if err != nil {
c.hub.opts.Logf("ws: ledger ack failed alert=%s event=%s: %v", c.identity.AlertID, id, err)
return
}
if cleared == 0 {
// A repeat ack (or one for an event this device never owed) is a no-op:
// nothing to record and no drain to check.
return
}
if err := db.RecordDelivered(ctx, c.identity.AlertID, c.identity.UserID); err != nil {
c.hub.opts.Logf("ws: delivered analytics alert=%s: %v", c.identity.AlertID, err)
}
if _, err := db.CompleteAlertDeliveries(ctx, c.identity.AlertID); err != nil {
c.hub.opts.Logf("ws: drain check alert=%s: %v", c.identity.AlertID, err)
}
}
// writePump owns all writes except the WriteControl calls in readPump (which // writePump owns all writes except the WriteControl calls in readPump (which
// gorilla explicitly allows concurrently). // gorilla explicitly allows concurrently).
func (c *client) writePump() { func (c *client) writePump() {
+96 -27
View File
@@ -43,7 +43,9 @@ import (
"sync" "sync"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -56,19 +58,21 @@ const (
DefaultSendBuffer = 16 DefaultSendBuffer = 16
DefaultMaxFrameSize = 4096 DefaultMaxFrameSize = 4096
// replayTimeout bounds the ledger read done during a handshake.
replayTimeout = 3 * time.Second
// ackCap bounds the per-event ack counters kept for observability. // ackCap bounds the per-event ack counters kept for observability.
ackCap = 256 ackCap = 256
) )
// AlarmFrame is the server -> client alarm frame. // AlarmFrame is the server -> client alarm frame.
type AlarmFrame struct { type AlarmFrame struct {
Type string `json:"type"` // always "alarm" Type string `json:"type"` // always "alarm"
AlertID string `json:"alertId"` AlertID string `json:"alertId"`
EventID string `json:"eventId"` EventID string `json:"eventId"`
Action string `json:"action"` // "trigger" | "disarm" Action string `json:"action"` // "trigger" | "disarm"
Message string `json:"message"` Message string `json:"message"`
ServerTime string `json:"serverTime"` // RFC3339, UTC ServerTime string `json:"serverTime"` // RFC3339, UTC
AutoStopSeconds int `json:"autoStopSeconds"`
} }
// AckFrame is the server -> client acknowledgement of a client ack. // AckFrame is the server -> client acknowledgement of a client ack.
@@ -114,6 +118,10 @@ type Options struct {
Authenticate func(r *http.Request) (*Identity, error) Authenticate func(r *http.Request) (*Identity, error)
// Touch refreshes subscriptions.last_seen_at. Optional. // Touch refreshes subscriptions.last_seen_at. Optional.
Touch func(ctx context.Context, subscriptionID uuid.UUID) error Touch func(ctx context.Context, subscriptionID uuid.UUID) error
// PendingReplay returns the trigger frames a subscription still owes (the
// durable delivery ledger). Nil uses the real ledger; tests inject a fake.
// A replay marks nothing: the debt clears on the client's ack/check-in.
PendingReplay func(ctx context.Context, alertID, subscriptionID uuid.UUID) ([]AlarmFrame, error)
PingInterval time.Duration PingInterval time.Duration
PongWait time.Duration PongWait time.Duration
@@ -142,22 +150,50 @@ func (o Options) withDefaults() Options {
if o.Logf == nil { if o.Logf == nil {
o.Logf = log.Printf o.Logf = log.Printf
} }
if o.PendingReplay == nil {
o.PendingReplay = ledgerReplay
}
return o return o
} }
// ledgerReplay is the production PendingReplay: the durable delivery ledger,
// converted into the same frames the hub broadcasts. Keeping the conversion here
// (rather than in db) avoids a db -> realtime cycle; the ledger stays the source
// of truth either way.
func ledgerReplay(ctx context.Context, alertID, subscriptionID uuid.UUID) ([]AlarmFrame, error) {
rows, err := db.PendingReplay(ctx, alertID, subscriptionID)
if err != nil {
return nil, err
}
frames := make([]AlarmFrame, 0, len(rows))
for _, r := range rows {
message := ""
if r.Message != nil {
message = *r.Message
}
frames = append(frames, AlarmFrame{
Type: "alarm",
AlertID: alertID.String(),
EventID: r.EventID.String(),
Action: alertstate.KindTrigger,
Message: message,
ServerTime: r.ServerTime.UTC().Format(time.RFC3339Nano),
})
}
return frames, nil
}
type lastEvent struct { type lastEvent struct {
frame AlarmFrame frame AlarmFrame
at time.Time at time.Time
} }
func (e lastEvent) active(now time.Time) bool { // active reports whether the cached event is still live. With no auto-stop an
if e.frame.Action != alertstate.KindTrigger { // alarm is live exactly while it is a trigger that has not yet been superseded
return false // by a disarm — and the cache only ever holds the newest event, so checking the
} // action is the whole test.
if e.frame.AutoStopSeconds <= 0 { func (e lastEvent) active() bool {
return true return e.frame.Action == alertstate.KindTrigger
}
return now.Before(e.at.Add(time.Duration(e.frame.AutoStopSeconds) * time.Second))
} }
// Hub fans alarm frames out to every WebSocket client of an alert. // Hub fans alarm frames out to every WebSocket client of an alert.
@@ -204,7 +240,7 @@ func (h *Hub) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet) w.Header().Set("Allow", http.MethodGet)
writeJSONError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "method not allowed") writeJSONError(w, http.StatusMethodNotAllowed, apierr.MethodNotAllowed, "method not allowed")
return return
} }
@@ -215,7 +251,7 @@ func (h *Hub) Handler() http.HandlerFunc {
writeJSONError(w, authErr.Status, authErr.Code, authErr.Message) writeJSONError(w, authErr.Status, authErr.Code, authErr.Message)
return return
} }
writeJSONError(w, http.StatusUnauthorized, "ERR_UNAUTHORIZED", "unauthorized") writeJSONError(w, http.StatusUnauthorized, apierr.Unauthorized, "unauthorized")
return return
} }
@@ -223,13 +259,13 @@ func (h *Hub) Handler() http.HandlerFunc {
closed := h.closed closed := h.closed
h.mu.Unlock() h.mu.Unlock()
if closed { if closed {
writeJSONError(w, http.StatusServiceUnavailable, "ERR_UNAVAILABLE", "server is shutting down") writeJSONError(w, http.StatusServiceUnavailable, apierr.Unavailable, "server is shutting down")
return return
} }
conn, err := h.upgrader.Upgrade(w, r, nil) conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
// Upgrade already wrote the HTTP error response. // Upgrade writes its own HTTP error with a non-empty status text.
h.opts.Logf("ws: upgrade failed alert=%s: %v", identity.AlertID, err) h.opts.Logf("ws: upgrade failed alert=%s: %v", identity.AlertID, err)
return return
} }
@@ -237,10 +273,26 @@ func (h *Hub) Handler() http.HandlerFunc {
c := newClient(h, conn, *identity) c := newClient(h, conn, *identity)
h.register(c) h.register(c)
// Durable replay: frames this subscription still owes from the delivery
// ledger. This is what makes a native service holding the socket (with
// the JS layer dead) fire an alarm it missed — the FGS has no access
// token and therefore cannot call the alarmStatus check-in at all. Sent
// after register/upgrade, into the buffered queue, bounded by the ledger
// query's LIMIT.
replayed := make(map[string]bool, 2)
for _, f := range h.replayPending(*identity) {
if b, err := json.Marshal(f); err == nil {
replayed[f.EventID] = true
c.trySend(b)
}
}
// Reconnect catch-up: replay the last event only while it is still // Reconnect catch-up: replay the last event only while it is still
// live, so a client that reconnects mid-alarm fires immediately and a // live, so a client that reconnects mid-alarm fires immediately and a
// client reconnecting after the alarm is never re-alarmed. // client reconnecting after the alarm is never re-alarmed. Skipped when
if f, ok := h.lastActive(identity.AlertID); ok { // the ledger replay above already sent that event (a live trigger whose
// ledger row is still unacked would otherwise arrive twice).
if f, ok := h.lastActive(identity.AlertID); ok && !replayed[f.EventID] {
if b, err := json.Marshal(f); err == nil { if b, err := json.Marshal(f); err == nil {
c.trySend(b) c.trySend(b)
} }
@@ -260,6 +312,24 @@ func (h *Hub) Handler() http.HandlerFunc {
} }
} }
// replayPending queries the ledger for this exact (alert, subscription) and
// returns the frames owed. Admin connections have no subscription and owe
// nothing. A failure is logged and degrades to the last-event catch-up: a broken
// ledger read must not refuse a socket that can still receive live alarms.
func (h *Hub) replayPending(identity Identity) []AlarmFrame {
if identity.SubscriptionID == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), replayTimeout)
defer cancel()
frames, err := h.opts.PendingReplay(ctx, identity.AlertID, *identity.SubscriptionID)
if err != nil {
h.opts.Logf("ws: pending delivery replay failed alert=%s: %v", identity.AlertID, err)
return nil
}
return frames
}
func (h *Hub) register(c *client) { func (h *Hub) register(c *client) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -344,7 +414,7 @@ func (h *Hub) lastActive(alertID uuid.UUID) (AlarmFrame, bool) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
e, ok := h.last[alertID] e, ok := h.last[alertID]
if !ok || !e.active(time.Now().UTC()) { if !ok || !e.active() {
return AlarmFrame{}, false return AlarmFrame{}, false
} }
return e.frame, true return e.frame, true
@@ -444,10 +514,9 @@ func (h *Hub) touch(c *client) {
} }
} }
// writeJSONError emits the handshake rejection envelope. apierr.Write supplies
// a canonical message when code/message are empty, so no rejection can answer a
// bare status.
func writeJSONError(w http.ResponseWriter, status int, code, message string) { func writeJSONError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json") apierr.Write(w, status, code, message)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{"code": code, "message": message},
})
} }
+133 -8
View File
@@ -21,9 +21,11 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -191,13 +193,12 @@ func TestReconnectReplaysLiveTrigger(t *testing.T) {
// Broadcast with nobody connected: the hub keeps it as the last event. // Broadcast with nobody connected: the hub keeps it as the last event.
frame := AlarmFrame{ frame := AlarmFrame{
Type: "alarm", Type: "alarm",
AlertID: alertID.String(), AlertID: alertID.String(),
EventID: uuid.NewString(), EventID: uuid.NewString(),
Action: "trigger", Action: "trigger",
Message: "still ringing", Message: "still ringing",
ServerTime: time.Now().UTC().Format(time.RFC3339Nano), ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
AutoStopSeconds: 300,
} }
if delivered := hub.Broadcast(alertID, frame); delivered != 0 { if delivered := hub.Broadcast(alertID, frame); delivered != 0 {
t.Fatalf("Broadcast with no clients delivered to %d, want 0", delivered) t.Fatalf("Broadcast with no clients delivered to %d, want 0", delivered)
@@ -226,7 +227,8 @@ func TestHandlerRejectsNonGet(t *testing.T) {
} }
var body struct { var body struct {
Error struct { Error struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"`
} `json:"error"` } `json:"error"`
} }
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
@@ -235,4 +237,127 @@ func TestHandlerRejectsNonGet(t *testing.T) {
if body.Error.Code == "" { if body.Error.Code == "" {
t.Fatalf("error body has no code: %+v", body) t.Fatalf("error body has no code: %+v", body)
} }
if strings.TrimSpace(body.Error.Message) == "" {
t.Fatalf("error body has no message: %+v", body)
}
}
// TestHandshakeRejectionAlwaysHasMessage proves the structural fallback: the
// Authenticate hook can hand the hub an AuthError with an empty or whitespace
// message and the handshake rejection still carries a readable one. A bare
// status on a failed upgrade is exactly the bug this pins.
func TestHandshakeRejectionAlwaysHasMessage(t *testing.T) {
alertID := uuid.New()
cases := []*AuthError{
{Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: ""},
{Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: " "},
{Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: ""},
{Status: http.StatusServiceUnavailable, Code: apierr.Unavailable, Message: ""},
}
for _, authErr := range cases {
authErr := authErr
hub := NewHub(Options{
Authenticate: func(r *http.Request) (*Identity, error) { return nil, authErr },
Logf: func(format string, v ...any) { t.Logf(format, v...) },
})
srv := httptest.NewServer(hub.Handler())
resp, err := http.Get(srv.URL + "/ws/alarm?alertId=" + alertID.String())
if err != nil {
srv.Close()
t.Fatalf("GET handshake: %v", err)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&body)
resp.Body.Close()
srv.Close()
if resp.StatusCode != authErr.Status {
t.Fatalf("status = %d, want %d", resp.StatusCode, authErr.Status)
}
if decodeErr != nil {
t.Fatalf("rejection body is not JSON: %v", decodeErr)
}
if body.Error.Code != authErr.Code {
t.Fatalf("code = %q, want %q", body.Error.Code, authErr.Code)
}
if strings.TrimSpace(body.Error.Message) == "" {
t.Fatalf("%d %s: rejection carried an empty message", authErr.Status, authErr.Code)
}
}
}
// TestConnectReplaysPendingDeliveriesBeforeAnyTrigger is the durable-delivery
// half of task-4: a subscription that owes a trigger from the ledger receives it
// on connect, before anyone broadcasts anything. After the client acks (and the
// ledger row is cleared, simulated by the fake flipping `owed`), a second
// reconnect replays nothing.
func TestConnectReplaysPendingDeliveriesBeforeAnyTrigger(t *testing.T) {
alertID := uuid.New()
subscriptionID := uuid.New()
eventID := uuid.New()
serverTime := time.Date(2026, 10, 18, 17, 5, 0, 123000000, time.UTC)
var mu sync.Mutex
owed := true
hub := NewHub(Options{
Authenticate: func(*http.Request) (*Identity, error) {
id := Identity{AlertID: alertID, UserID: uuid.New(), SubscriptionID: &subscriptionID}
return &id, nil
},
PendingReplay: func(_ context.Context, gotAlert, gotSub uuid.UUID) ([]AlarmFrame, error) {
if gotAlert != alertID || gotSub != subscriptionID {
t.Errorf("replay queried (%s, %s), want (%s, %s)", gotAlert, gotSub, alertID, subscriptionID)
}
mu.Lock()
defer mu.Unlock()
if !owed {
return nil, nil
}
return []AlarmFrame{{
Type: "alarm",
AlertID: alertID.String(),
EventID: eventID.String(),
Action: "trigger",
Message: "missed while offline",
ServerTime: serverTime.Format(time.RFC3339Nano),
}}, nil
},
Logf: func(format string, v ...any) { t.Logf(format, v...) },
})
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go hub.Run(ctx)
srv := httptest.NewServer(hub.Handler())
t.Cleanup(srv.Close)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
first := dial(t, wsURL)
got := readAlarm(t, first)
if got.EventID != eventID.String() || got.Message != "missed while offline" || got.Action != "trigger" {
t.Fatalf("replay frame = %+v, want the owed trigger %s", got, eventID)
}
if err := first.WriteJSON(ClientFrame{Type: "ack", EventID: eventID.String()}); err != nil {
t.Fatalf("ack: %v", err)
}
// Give the ack a moment to be processed by the read pump, then simulate the
// ledger row having been cleared by it (AckDelivery is covered against a real
// database in db/deliveries_db_test.go).
time.Sleep(50 * time.Millisecond)
mu.Lock()
owed = false
mu.Unlock()
second := dial(t, wsURL)
if err := second.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil {
t.Fatalf("set deadline: %v", err)
}
if _, _, err := second.ReadMessage(); err == nil {
t.Fatal("reconnect after ack replayed a frame, want silence")
}
} }
+6 -7
View File
@@ -44,13 +44,12 @@ func TestBroadcastLatency(t *testing.T) {
for i := 0; i < iterations; i++ { for i := 0; i < iterations; i++ {
eventID := uuid.NewString() eventID := uuid.NewString()
frame := AlarmFrame{ frame := AlarmFrame{
Type: "alarm", Type: "alarm",
AlertID: alertID.String(), AlertID: alertID.String(),
EventID: eventID, EventID: eventID,
Action: "trigger", Action: "trigger",
Message: "latency probe", Message: "latency probe",
ServerTime: time.Now().UTC().Format(time.RFC3339Nano), ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
AutoStopSeconds: 300,
} }
start := time.Now() start := time.Now()
if delivered := hub.Broadcast(alertID, frame); delivered != 2 { if delivered := hub.Broadcast(alertID, frame); delivered != 2 {
+12 -14
View File
@@ -36,26 +36,24 @@ const NotifyChannel = "alarm_events"
// the one that served the trigger request — fans the event out to its own // the one that served the trigger request — fans the event out to its own
// subscribers. // subscribers.
type NotifyPayload struct { type NotifyPayload struct {
AlertID string `json:"alertId"` AlertID string `json:"alertId"`
EventID string `json:"eventId"` EventID string `json:"eventId"`
Action string `json:"action"` Action string `json:"action"`
Message string `json:"message"` Message string `json:"message"`
AutoStopSeconds int `json:"autoStopSeconds"` CreatedAtMs int64 `json:"createdAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
} }
// Frame converts the transport payload into the wire frame. ServerTime is the // Frame converts the transport payload into the wire frame. ServerTime is the
// EVENT time, not the delivery time, so every instance and every client agrees // EVENT time, not the delivery time, so every instance and every client agrees
// on when the alarm started (and therefore on the auto-stop countdown). // on when the alarm started.
func (p NotifyPayload) Frame() AlarmFrame { func (p NotifyPayload) Frame() AlarmFrame {
return AlarmFrame{ return AlarmFrame{
Type: "alarm", Type: "alarm",
AlertID: p.AlertID, AlertID: p.AlertID,
EventID: p.EventID, EventID: p.EventID,
Action: p.Action, Action: p.Action,
Message: p.Message, Message: p.Message,
ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano), ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano),
AutoStopSeconds: p.AutoStopSeconds,
} }
} }