Skip to content

Commit 8e2316f

Browse files
committed
refactor: simplify/improve status alert handling (#1519)
also adds new functionality to restore any pending down alerts that were lost by hub restart before creation
1 parent 0d3dfcb commit 8e2316f

7 files changed

Lines changed: 779 additions & 270 deletions

File tree

‎internal/alerts/alerts.go‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ type hubLike interface {
2121

2222
type AlertManager struct {
2323
hub hubLike
24-
alertQueue chan alertTask
25-
stopChan chan struct{}
24+
stopOnce sync.Once
2625
pendingAlerts sync.Map
2726
}
2827

@@ -98,12 +97,9 @@ var supportsTitle = map[string]struct{}{
9897
// NewAlertManager creates a new AlertManager instance.
9998
func NewAlertManager(app hubLike) *AlertManager {
10099
am := &AlertManager{
101-
hub: app,
102-
alertQueue: make(chan alertTask, 5),
103-
stopChan: make(chan struct{}),
100+
hub: app,
104101
}
105102
am.bindEvents()
106-
go am.startWorker()
107103
return am
108104
}
109105

@@ -112,6 +108,16 @@ func (am *AlertManager) bindEvents() {
112108
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
113109
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
114110
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
111+
112+
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
113+
if err := resolveStatusAlerts(e.App); err != nil {
114+
e.App.Logger().Error("Failed to resolve stale status alerts", "err", err)
115+
}
116+
if err := am.restorePendingStatusAlerts(); err != nil {
117+
e.App.Logger().Error("Failed to restore pending status alerts", "err", err)
118+
}
119+
return e.Next()
120+
})
115121
}
116122

117123
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours

‎internal/alerts/alerts_quiet_hours_test.go‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func TestAlertSilencedOneTime(t *testing.T) {
4949

5050
// Get alert manager
5151
am := alerts.NewAlertManager(hub)
52-
defer am.StopWorker()
52+
defer am.Stop()
5353

5454
// Test that alert is silenced
5555
silenced := am.IsNotificationSilenced(user.Id, system.Id)
@@ -106,7 +106,7 @@ func TestAlertSilencedDaily(t *testing.T) {
106106

107107
// Get alert manager
108108
am := alerts.NewAlertManager(hub)
109-
defer am.StopWorker()
109+
defer am.Stop()
110110

111111
// Get current hour and create a window that includes current time
112112
now := time.Now().UTC()
@@ -170,7 +170,7 @@ func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
170170

171171
// Get alert manager
172172
am := alerts.NewAlertManager(hub)
173-
defer am.StopWorker()
173+
defer am.Stop()
174174

175175
// Create a window that crosses midnight: 22:00 - 02:00
176176
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
@@ -211,7 +211,7 @@ func TestAlertSilencedGlobal(t *testing.T) {
211211

212212
// Get alert manager
213213
am := alerts.NewAlertManager(hub)
214-
defer am.StopWorker()
214+
defer am.Stop()
215215

216216
// Create a global quiet hours window (no system specified)
217217
now := time.Now().UTC()
@@ -250,7 +250,7 @@ func TestAlertSilencedSystemSpecific(t *testing.T) {
250250

251251
// Get alert manager
252252
am := alerts.NewAlertManager(hub)
253-
defer am.StopWorker()
253+
defer am.Stop()
254254

255255
// Create a system-specific quiet hours window for system1 only
256256
now := time.Now().UTC()
@@ -296,7 +296,7 @@ func TestAlertSilencedMultiUser(t *testing.T) {
296296

297297
// Get alert manager
298298
am := alerts.NewAlertManager(hub)
299-
defer am.StopWorker()
299+
defer am.Stop()
300300

301301
// Create a quiet hours window for user1 only
302302
now := time.Now().UTC()
@@ -417,7 +417,7 @@ func TestAlertSilencedNoWindows(t *testing.T) {
417417

418418
// Get alert manager
419419
am := alerts.NewAlertManager(hub)
420-
defer am.StopWorker()
420+
defer am.Stop()
421421

422422
// Without any quiet hours windows, alert should NOT be silenced
423423
silenced := am.IsNotificationSilenced(user.Id, system.Id)

‎internal/alerts/alerts_status.go‎

Lines changed: 101 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -9,63 +9,25 @@ import (
99
"github.com/pocketbase/pocketbase/core"
1010
)
1111

12-
type alertTask struct {
13-
action string // "schedule" or "cancel"
14-
systemName string
15-
alertRecord *core.Record
16-
delay time.Duration
17-
}
18-
1912
type alertInfo struct {
2013
systemName string
2114
alertRecord *core.Record
2215
expireTime time.Time
16+
timer *time.Timer
2317
}
2418

25-
// startWorker is a long-running goroutine that processes alert tasks
26-
// every x seconds. It must be running to process status alerts.
27-
func (am *AlertManager) startWorker() {
28-
processPendingAlerts := time.Tick(15 * time.Second)
29-
30-
// check for status alerts that are not resolved when system comes up
31-
// (can be removed if we figure out core bug in #1052)
32-
checkStatusAlerts := time.Tick(561 * time.Second)
33-
34-
for {
35-
select {
36-
case <-am.stopChan:
37-
return
38-
case task := <-am.alertQueue:
39-
switch task.action {
40-
case "schedule":
41-
am.pendingAlerts.Store(task.alertRecord.Id, &alertInfo{
42-
systemName: task.systemName,
43-
alertRecord: task.alertRecord,
44-
expireTime: time.Now().Add(task.delay),
45-
})
46-
case "cancel":
47-
am.pendingAlerts.Delete(task.alertRecord.Id)
19+
// Stop cancels all pending status alert timers.
20+
func (am *AlertManager) Stop() {
21+
am.stopOnce.Do(func() {
22+
am.pendingAlerts.Range(func(key, value any) bool {
23+
info := value.(*alertInfo)
24+
if info.timer != nil {
25+
info.timer.Stop()
4826
}
49-
case <-checkStatusAlerts:
50-
resolveStatusAlerts(am.hub)
51-
case <-processPendingAlerts:
52-
// Check for expired alerts every tick
53-
now := time.Now()
54-
for key, value := range am.pendingAlerts.Range {
55-
info := value.(*alertInfo)
56-
if now.After(info.expireTime) {
57-
// Downtime delay has passed, process alert
58-
am.sendStatusAlert("down", info.systemName, info.alertRecord)
59-
am.pendingAlerts.Delete(key)
60-
}
61-
}
62-
}
63-
}
64-
}
65-
66-
// StopWorker shuts down the AlertManager.worker goroutine
67-
func (am *AlertManager) StopWorker() {
68-
close(am.stopChan)
27+
am.pendingAlerts.Delete(key)
28+
return true
29+
})
30+
})
6931
}
7032

7133
// HandleStatusAlerts manages the logic when system status changes.
@@ -103,38 +65,43 @@ func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record,
10365
return alertRecords, nil
10466
}
10567

106-
// Schedules delayed "down" alerts for each alert record.
68+
// handleSystemDown manages the logic when a system status changes to "down". It schedules pending alerts for each alert record.
10769
func (am *AlertManager) handleSystemDown(systemName string, alertRecords []*core.Record) {
10870
for _, alertRecord := range alertRecords {
109-
// Continue if alert is already scheduled
110-
if _, exists := am.pendingAlerts.Load(alertRecord.Id); exists {
111-
continue
112-
}
113-
// Schedule by adding to queue
11471
min := max(1, alertRecord.GetInt("min"))
115-
am.alertQueue <- alertTask{
116-
action: "schedule",
117-
systemName: systemName,
118-
alertRecord: alertRecord,
119-
delay: time.Duration(min) * time.Minute,
120-
}
72+
am.schedulePendingStatusAlert(systemName, alertRecord, time.Duration(min)*time.Minute)
12173
}
12274
}
12375

76+
// schedulePendingStatusAlert sets up a timer to send a "down" alert after the specified delay if the system is still down.
77+
// It returns true if the alert was scheduled, or false if an alert was already pending for the given alert record.
78+
func (am *AlertManager) schedulePendingStatusAlert(systemName string, alertRecord *core.Record, delay time.Duration) bool {
79+
alert := &alertInfo{
80+
systemName: systemName,
81+
alertRecord: alertRecord,
82+
expireTime: time.Now().Add(delay),
83+
}
84+
85+
storedAlert, loaded := am.pendingAlerts.LoadOrStore(alertRecord.Id, alert)
86+
if loaded {
87+
return false
88+
}
89+
90+
stored := storedAlert.(*alertInfo)
91+
stored.timer = time.AfterFunc(time.Until(stored.expireTime), func() {
92+
am.processPendingAlert(alertRecord.Id)
93+
})
94+
return true
95+
}
96+
12497
// handleSystemUp manages the logic when a system status changes to "up".
12598
// It cancels any pending alerts and sends "up" alerts.
12699
func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.Record) {
127100
for _, alertRecord := range alertRecords {
128-
alertRecordID := alertRecord.Id
129101
// If alert exists for record, delete and continue (down alert not sent)
130-
if _, exists := am.pendingAlerts.Load(alertRecordID); exists {
131-
am.alertQueue <- alertTask{
132-
action: "cancel",
133-
alertRecord: alertRecord,
134-
}
102+
if am.cancelPendingAlert(alertRecord.Id) {
135103
continue
136104
}
137-
// No alert scheduled for this record, send "up" alert only if "down" was triggered
138105
if !alertRecord.GetBool("triggered") {
139106
continue
140107
}
@@ -144,6 +111,36 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
144111
}
145112
}
146113

114+
// cancelPendingAlert stops the timer and removes the pending alert for the given alert ID. Returns true if a pending alert was found and cancelled.
115+
func (am *AlertManager) cancelPendingAlert(alertID string) bool {
116+
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
117+
if !loaded {
118+
return false
119+
}
120+
121+
info := value.(*alertInfo)
122+
if info.timer != nil {
123+
info.timer.Stop()
124+
}
125+
return true
126+
}
127+
128+
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
129+
func (am *AlertManager) processPendingAlert(alertID string) {
130+
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
131+
if !loaded {
132+
return
133+
}
134+
135+
info := value.(*alertInfo)
136+
if info.alertRecord.GetBool("triggered") {
137+
return
138+
}
139+
if err := am.sendStatusAlert("down", info.systemName, info.alertRecord); err != nil {
140+
am.hub.Logger().Error("Failed to send alert", "err", err)
141+
}
142+
}
143+
147144
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
148145
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
149146
switch alertStatus {
@@ -177,8 +174,8 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
177174
})
178175
}
179176

180-
// resolveStatusAlerts resolves any status alerts that weren't resolved
181-
// when system came up (https://github.com/henrygd/beszel/issues/1052)
177+
// resolveStatusAlerts resolves any triggered status alerts that weren't resolved
178+
// when system came up (https://github.com/henrygd/beszel/issues/1052).
182179
func resolveStatusAlerts(app core.App) error {
183180
db := app.DB()
184181
// Find all active status alerts where the system is actually up
@@ -208,3 +205,36 @@ func resolveStatusAlerts(app core.App) error {
208205
}
209206
return nil
210207
}
208+
209+
// restorePendingStatusAlerts re-queues untriggered status alerts for systems that
210+
// are still down after a hub restart. This rebuilds the lost in-memory timer state.
211+
func (am *AlertManager) restorePendingStatusAlerts() error {
212+
type pendingStatusAlert struct {
213+
AlertID string `db:"alert_id"`
214+
SystemName string `db:"system_name"`
215+
}
216+
217+
var pending []pendingStatusAlert
218+
err := am.hub.DB().NewQuery(`
219+
SELECT a.id AS alert_id, s.name AS system_name
220+
FROM alerts a
221+
JOIN systems s ON a.system = s.id
222+
WHERE a.name = 'Status'
223+
AND a.triggered = false
224+
AND s.status = 'down'
225+
`).All(&pending)
226+
if err != nil {
227+
return err
228+
}
229+
230+
for _, item := range pending {
231+
alertRecord, err := am.hub.FindRecordById("alerts", item.AlertID)
232+
if err != nil {
233+
return err
234+
}
235+
min := max(1, alertRecord.GetInt("min"))
236+
am.schedulePendingStatusAlert(item.SystemName, alertRecord, time.Duration(min)*time.Minute)
237+
}
238+
239+
return nil
240+
}

0 commit comments

Comments
 (0)