fix(alarms): give up after three runs without delivery (FLAW-12)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s

New column alarm_failures counts ticker runs in which no channel
delivered; at three the alarm is marked as done and a warning is logged
instead of retrying every minute forever. Success, a new due date or
reopening the task starts over with three attempts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 12:22:13 +01:00
1 parent b5f926bcf7
commit d1982816c1
8 files changed
+80 -13

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
## Hintergrundjob: Alarme ## Hintergrundjob: Alarme
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md). `startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
## Hintergrundjob: Verdichtung der Notiz-Synchronisation ## Hintergrundjob: Verdichtung der Notiz-Synchronisation
+1 -1
View File
@@ -20,7 +20,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
| Tabelle | Zweck | Wichtige Spalten | | Tabelle | Zweck | Wichtige Spalten |
|---|---|---| |---|---|---|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` | | `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) | | `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` | | `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) | | `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) | | `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
+2 -2
View File
@@ -21,7 +21,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen; ~~Mindestpasswort 4 Zeichen~~ | Mittel | | FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen; ~~Mindestpasswort 4 Zeichen~~ | Mittel |
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | | FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** | | FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder; endlose Wiederholung bei Fehlern~~ | **Behoben** |
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel | | FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel | | FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | | FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
@@ -111,7 +111,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen. - **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
### FLAW-12: Alarm-Logik ### FLAW-12: Alarm-Logik
- **Teilweise behoben:** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` zurück. Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`. Offen bleibt der letzte Punkt (kein Backoff). - **Status: Behoben.** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` und `alarm_failures` zurück. Nach 3 Durchläufen, in denen kein Kanal zustellt, wird der Alarm aufgegeben (Warnung im Log). Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`, `TestAlarmGivesUpAfterThreeFailedRuns`.
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`. - **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
- **Probleme:** - **Probleme:**
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar. - `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
+1 -1
View File
@@ -57,6 +57,6 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt). - `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451). - Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Offen ([FLAW-12](../flaws.md)): Schlagen alle Kanäle fehl, wird jede Minute erneut versucht. URLs werden geprüft ([FLAW-05](../flaws.md)). Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche ([FLAW-12](../flaws.md)). URLs werden geprüft ([FLAW-05](../flaws.md)).
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag. Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
+2
View File
@@ -151,6 +151,8 @@ func (s *Server) startAlarmTicker() {
if sentAny { if sentAny {
_ = s.store.MarkTaskAlarmSent(a.TaskID) _ = s.store.MarkTaskAlarmSent(a.TaskID)
} else if gaveUp, _ := s.store.RecordTaskAlarmFailure(a.TaskID); gaveUp {
slog.Warn("Aufgaben-Alarm nach 3 Fehlversuchen aufgegeben", "task_id", a.TaskID, "title", a.Title)
} }
} }
} }
+47
View File
@@ -138,3 +138,50 @@ func TestAlarmResetsOnNewDueDateOrReopen(t *testing.T) {
t.Errorf("reopening must re-arm the alarm") t.Errorf("reopening must re-arm the alarm")
} }
} }
func TestAlarmGivesUpAfterThreeFailedRuns(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "alarmfail.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
u, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
task, _ := store.CreateThoughtWithDetails(u.ID, "Webhook kaputt", "normal", "manual", "2026-10-30", "", "todo", "")
_, _ = store.UpdateThoughtTriage(u.ID, task.ID, "keep", "task", "", "", "")
pending := func() bool {
alarms, _ := store.GetDueTasksForAlarm()
for _, a := range alarms {
if a.TaskID == task.ID {
return true
}
}
return false
}
for run := 1; run <= MaxAlarmFailures; run++ {
gaveUp, err := store.RecordTaskAlarmFailure(task.ID)
if err != nil {
t.Fatalf("run %d: %v", run, err)
}
if gaveUp != (run == MaxAlarmFailures) || pending() == (run == MaxAlarmFailures) {
t.Fatalf("run %d: gaveUp=%v pending=%v", run, gaveUp, pending())
}
}
// A new due date gets three fresh attempts
later := "2026-11-02"
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &later, nil, nil, nil, nil, nil, nil)
if !pending() {
t.Fatalf("new due date must re-arm the alarm")
}
if gaveUp, _ := store.RecordTaskAlarmFailure(task.ID); gaveUp {
t.Errorf("failure counter must start over after a new due date")
}
// Success clears the counter
_ = store.MarkTaskAlarmSent(task.ID)
var failures int
_ = store.db.QueryRow(`SELECT alarm_failures FROM thoughts WHERE id = ?`, task.ID).Scan(&failures)
if failures != 0 {
t.Errorf("successful alarm should reset failures, got %d", failures)
}
}
+1
View File
@@ -375,6 +375,7 @@ func (s *Store) migrate() error {
// Idempotent column migrations for existing databases // Idempotent column migrations for existing databases
columnMigrations := []string{ columnMigrations := []string{
"ALTER TABLE thoughts ADD COLUMN alarm_failures INTEGER NOT NULL DEFAULT 0;",
"ALTER TABLE documents ADD COLUMN archived_at TEXT NOT NULL DEFAULT '';", "ALTER TABLE documents ADD COLUMN archived_at TEXT NOT NULL DEFAULT '';",
"ALTER TABLE documents ADD COLUMN archived_by TEXT NOT NULL DEFAULT '';", "ALTER TABLE documents ADD COLUMN archived_by TEXT NOT NULL DEFAULT '';",
"ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';",
+25 -8
View File
@@ -251,8 +251,9 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
} }
_, err = tx.Exec( _, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?, `UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END,
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID, alarm_failures = CASE WHEN ? = 0 THEN 0 ELSE alarm_failures END WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, isCompleted, it.ID,
) )
} else { } else {
_, err = tx.Exec( _, err = tx.Exec(
@@ -388,9 +389,10 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
_, err = s.db.Exec( _, err = s.db.Exec(
`UPDATE thoughts `UPDATE thoughts
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?, SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?,
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END,
alarm_failures = CASE WHEN due_date != ? THEN 0 ELSE alarm_failures END
WHERE id = ? AND user_id = ?`, WHERE id = ? AND user_id = ?`,
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID, string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, dueDate, thoughtID, userID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -479,7 +481,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
} }
// A new due date or a reopened task gets its alarm again // A new due date or a reopened task gets its alarm again
if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) { if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) {
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID) _, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0, alarm_failures = 0 WHERE id = ?`, thoughtID)
thought.AlarmSent = false thought.AlarmSent = false
} }
if taskListID != nil { if taskListID != nil {
@@ -572,8 +574,9 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
doneInt = 1 doneInt = 1
kanban = "done" kanban = "done"
} }
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END,
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt) alarm_failures = CASE WHEN ? = 0 THEN 0 ELSE alarm_failures END
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, doneInt, taskID, userID, doneInt)
if checked && res != nil { if checked && res != nil {
if n, _ := res.RowsAffected(); n > 0 { if n, _ := res.RowsAffected(); n > 0 {
s.spawnNextOccurrence(taskID) s.spawnNextOccurrence(taskID)
@@ -634,6 +637,20 @@ func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
} }
func (s *Store) MarkTaskAlarmSent(taskID string) error { func (s *Store) MarkTaskAlarmSent(taskID string) error {
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID) _, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1, alarm_failures = 0 WHERE id = ?`, taskID)
return err return err
} }
// MaxAlarmFailures: after this many runs in which no channel delivered, the alarm is given up instead of
// being retried every minute. A new due date or reopening the task starts over.
const MaxAlarmFailures = 3
// RecordTaskAlarmFailure counts a run without any successful channel and reports whether the alarm was
// given up now.
func (s *Store) RecordTaskAlarmFailure(taskID string) (gaveUp bool, err error) {
var failures int
err = s.db.QueryRow(`UPDATE thoughts SET alarm_failures = alarm_failures + 1,
alarm_sent = CASE WHEN alarm_failures + 1 >= ? THEN 1 ELSE alarm_sent END
WHERE id = ? RETURNING alarm_failures`, MaxAlarmFailures, taskID).Scan(&failures)
return err == nil && failures >= MaxAlarmFailures, err
}