Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
both; `task release:chart` handles chart-only releases and the initial chart
publication.

### Fixed

- A recurrence attached to an already-resolved incident now reopens that
incident for recovery tracking, so resolving the recurrence updates the
existing Slack card back to `resolved`. Duplicate resolved deliveries remain
silent. (#81)

## [0.13.7] - 2026-09-02

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions internal/correlator/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ func (c *Correlator) attachOccurrence(ctx context.Context, a store.Alert, inc st
if _, err := c.st.InsertOccurrenceAndAttach(ctx, occ, a.ID, a.ReceivedAt); err != nil {
return fmt.Errorf("correlator: attach occurrence: %w", err)
}
if inc.Status == "resolved" {
inc.Status = "analyzed"
}

if c.auditor != nil {
if err := c.auditor.Append(ctx, "correlator", "incident.occurrence_attached", map[string]any{
Expand Down
12 changes: 6 additions & 6 deletions internal/correlator/attach_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,10 +383,10 @@ func TestMaybeAttach_CeilingRejudge(t *testing.T) {
}
}

// TestMaybeAttach_AttachToResolvedKeepsStatus covers R1: a firing re-fire whose
// condition had fully recovered attaches to the resolved incident (a new
// episode) without reversing its status.
func TestMaybeAttach_AttachToResolvedKeepsStatus(t *testing.T) {
// TestMaybeAttach_AttachToResolvedReopensIncident covers issue 81: a firing
// re-fire whose condition had fully recovered attaches to the resolved incident
// as a new episode and reopens it for a later one-shot resolution notification.
func TestMaybeAttach_AttachToResolvedReopensIncident(t *testing.T) {
st := openStore(t)
c, _ := newCorrelatorFor(t, st)
ctx := context.Background()
Expand All @@ -406,8 +406,8 @@ func TestMaybeAttach_AttachToResolvedKeepsStatus(t *testing.T) {
t.Errorf("occurrences = %d, want 1 (a resolved incident still collects occurrences)", occCount(t, st, "inc_1"))
}
inc, _ := st.GetIncidentByID(ctx, "inc_1")
if inc.Status != "resolved" {
t.Errorf("status = %q, want resolved (an attach never reverses status)", inc.Status)
if inc.Status != "analyzed" {
t.Errorf("status = %q, want analyzed (a new episode reopens resolution)", inc.Status)
}
}

Expand Down
85 changes: 85 additions & 0 deletions internal/correlator/resolved_recurrence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: FSL-1.1-ALv2

package correlator

import (
"context"
"testing"
"time"

"github.com/alertint/alertint-agent/internal/store"
)

type resolutionRecorder struct {
calls []store.Incident
}

func (r *resolutionRecorder) OnIncidentResolved(_ context.Context, inc store.Incident) error {
r.calls = append(r.calls, inc)
return nil
}

func TestResolvedRecurrenceNotifiesWhenItResolvesAgain(t *testing.T) {
st := openStore(t)
c, _ := newCorrelatorFor(t, st)
resolved := &resolutionRecorder{}
c.SetResolutionNotifier(resolved)
ctx := context.Background()
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)

original := firingAlert("fp-original", "DiskFull", "warning", now.Add(-5*time.Minute), false)
seedJudged(t, st, "inc_1", "analyzed", original.ReceivedAt, now.Add(-10*time.Minute), original)

resolve := func(a store.Alert, at time.Time) store.Alert {
t.Helper()
a.Status = "resolved"
a.ReceivedAt = at
stored, err := st.UpsertAlertByFingerprint(ctx, a)
if err != nil {
t.Fatalf("upsert resolved %s: %v", a.Fingerprint, err)
}
if err := c.handleAlert(ctx, stored); err != nil {
t.Fatalf("handle resolved %s: %v", a.Fingerprint, err)
}
return stored
}

resolve(original, now)
if got := len(resolved.calls); got != 1 {
t.Fatalf("resolution notifications after initial recovery = %d, want 1", got)
}

recurrence := original
recurrence.ReceivedAt = now.Add(time.Minute)
recurrence, err := st.UpsertAlertByFingerprint(ctx, recurrence)
if err != nil {
t.Fatalf("upsert recurrence: %v", err)
}
if err := c.handleAlert(ctx, recurrence); err != nil {
t.Fatalf("handle recurrence: %v", err)
}
reopened, err := st.GetIncidentByID(ctx, "inc_1")
if err != nil {
t.Fatalf("get reopened incident: %v", err)
}
if reopened.Status != "analyzed" {
t.Fatalf("incident status after recurrence = %q, want analyzed", reopened.Status)
}

resolve(recurrence, now.Add(2*time.Minute))
if got := len(resolved.calls); got != 2 {
t.Fatalf("resolution notifications after recurrence recovery = %d, want 2", got)
}
inc, err := st.GetIncidentByID(ctx, "inc_1")
if err != nil {
t.Fatalf("get incident: %v", err)
}
if inc.Status != "resolved" {
t.Fatalf("incident status after recurrence recovery = %q, want resolved", inc.Status)
}

resolve(recurrence, now.Add(3*time.Minute))
if got := len(resolved.calls); got != 2 {
t.Fatalf("resolution notifications after duplicate resolved delivery = %d, want 2", got)
}
}
10 changes: 8 additions & 2 deletions internal/notify/slack/occurrence.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ func milestoneHit(episodes int) bool {

// cancelPendingOcc drops any armed trailing count-edit for an incident so a
// coalesced stale render can't land after a re-judgment's fresh finding edit
// (single-writer hygiene, ADR-0019). A flush already in flight is an accepted,
// self-correcting residual.
// (single-writer hygiene, ADR-0019). Per-incident card-write serialization
// makes a flush already in flight finish before the authoritative finding edit.
func (n *Notifier) cancelPendingOcc(incidentID string) {
n.occMu.Lock()
defer n.occMu.Unlock()
Expand Down Expand Up @@ -255,6 +255,9 @@ func averageCadence(s store.OccurrenceStats) string {
// incident per occEditThrottle, coalesced, with a trailing flush that lands the
// final count.
func (n *Notifier) editOccurrenceCard(ctx context.Context, ev notify.RecurrenceEvent, ts, ch string) error {
unlock := n.lockCardWrite(ev.Incident.ID)
defer unlock()

occurrences := ev.Stats.Episodes()
edit := pendingEdit{
ts: ts,
Expand Down Expand Up @@ -295,6 +298,9 @@ func (n *Notifier) editOccurrenceCard(ctx context.Context, ev notify.RecurrenceE
// It runs on the timer goroutine, so it re-takes the lock and reads the latest
// pending edit (a newer attach may have superseded it).
func (n *Notifier) flushOccurrence(incidentID string) {
unlock := n.lockCardWrite(incidentID)
defer unlock()

n.occMu.Lock()
st := n.occ[incidentID]
if st == nil {
Expand Down
112 changes: 111 additions & 1 deletion internal/notify/slack/occurrence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func (noopTimer) Stop() bool { return true }

// occNotifier builds a Slack notifier wired with the fake client/store and a
// controllable clock + captured trailing-flush timer.
func occNotifier(t *testing.T, client *fakeSlack, ts *fakeThreadStore) (*Notifier, *time.Time, *func()) {
func occNotifier(t *testing.T, client SlackClient, ts *fakeThreadStore) (*Notifier, *time.Time, *func()) {
t.Helper()
n := NewWithClient(client, "chan", "", "change-gated", ts, nil)
clockNow := time.Unix(1_000_000, 0).UTC()
Expand Down Expand Up @@ -478,6 +478,116 @@ func TestRecurrence_PendingFlushCanceledOnRejudge(t *testing.T) {
}
}

func TestRecurrence_PendingFlushCanceledOnResolution(t *testing.T) {
client := newFakeSlack(t)
n, now, _ := occNotifier(t, client, &fakeThreadStore{})
stopped := false
n.after = func(_ time.Duration, _ func()) stopper { return &recordingStopper{stopped: &stopped} }
if err := n.OnOccurrenceAttached(context.Background(), recurEvent("none", 1, *now)); err != nil {
t.Fatalf("attach 1: %v", err)
}
if err := n.OnOccurrenceAttached(context.Background(), recurEvent("none", 2, *now)); err != nil {
t.Fatalf("attach 2: %v", err)
}

if err := n.Notify(context.Background(), notify.Finding{
IncidentID: "inc12345678",
AnalysisName: "Disk full",
Status: "resolved",
FirstAlertAt: now.Add(-time.Hour),
AnalyzedAt: *now,
}); err != nil {
t.Fatalf("notify resolved: %v", err)
}
if !stopped {
t.Error("pending trailing count-edit timer was not stopped on resolution")
}
}

type blockingUpdateClient struct {
mu sync.Mutex
startedUpdates int
completed []int
secondStarted chan struct{}
thirdStarted chan struct{}
releaseSecond chan struct{}
}

func (c *blockingUpdateClient) PostMessageContext(_ context.Context, _ string, _ ...slacklib.MsgOption) (string, string, error) {
return "chan", "ts-1", nil
}

func (c *blockingUpdateClient) UpdateMessageContext(_ context.Context, _, _ string, _ ...slacklib.MsgOption) (string, string, string, error) {
c.mu.Lock()
c.startedUpdates++
call := c.startedUpdates
c.mu.Unlock()
switch call {
case 2:
close(c.secondStarted)
<-c.releaseSecond
case 3:
close(c.thirdStarted)
}
c.mu.Lock()
c.completed = append(c.completed, call)
c.mu.Unlock()
return "chan", "ts-1", "", nil
}

func TestRecurrence_InFlightFlushCompletesBeforeResolution(t *testing.T) {
client := &blockingUpdateClient{
secondStarted: make(chan struct{}),
thirdStarted: make(chan struct{}),
releaseSecond: make(chan struct{}),
}
n, now, flush := occNotifier(t, client, &fakeThreadStore{})
if err := n.OnOccurrenceAttached(context.Background(), recurEvent("none", 1, *now)); err != nil {
t.Fatalf("attach 1: %v", err)
}
if err := n.OnOccurrenceAttached(context.Background(), recurEvent("none", 2, *now)); err != nil {
t.Fatalf("attach 2: %v", err)
}

flushDone := make(chan struct{})
go func() {
(*flush)()
close(flushDone)
}()
<-client.secondStarted

resolvedDone := make(chan error, 1)
go func() {
resolvedDone <- n.Notify(context.Background(), notify.Finding{
IncidentID: "inc12345678",
AnalysisName: "Disk full",
Status: "resolved",
FirstAlertAt: now.Add(-time.Hour),
AnalyzedAt: *now,
})
}()

resolutionOvertookFlush := false
select {
case <-client.thirdStarted:
resolutionOvertookFlush = true
case <-time.After(250 * time.Millisecond):
}
close(client.releaseSecond)
<-flushDone
if err := <-resolvedDone; err != nil {
t.Fatalf("notify resolved: %v", err)
}
if resolutionOvertookFlush {
t.Error("resolution update started before the in-flight recurrence edit completed")
}
client.mu.Lock()
defer client.mu.Unlock()
if got := fmt.Sprint(client.completed); got != "[1 2 3]" {
t.Errorf("completed root-card writes = %s, want [1 2 3]", got)
}
}

type recordingStopper struct{ stopped *bool }

func (r *recordingStopper) Stop() bool { *r.stopped = true; return true }
40 changes: 40 additions & 0 deletions internal/notify/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ type Notifier struct {
after func(d time.Duration, fn func()) stopper
occMu sync.Mutex
occ map[string]*occThrottle

// Root-card writes are serialized per incident so a trailing occurrence
// edit that is already in flight must finish before a later resolved or
// re-judged finding writes the authoritative card state.
cardWritesMu sync.Mutex
cardWrites map[string]*cardWriteState
}

type cardWriteState struct {
mu sync.Mutex
refs int
}

// Probe verifies the bot token against the Slack auth.test API. Used by
Expand Down Expand Up @@ -95,6 +106,7 @@ func newNotifier(client SlackClient, channel, minSeverity, recurrenceMode string
now: time.Now,
after: func(d time.Duration, fn func()) stopper { return time.AfterFunc(d, fn) },
occ: make(map[string]*occThrottle),
cardWrites: make(map[string]*cardWriteState),
}
}

Expand All @@ -105,12 +117,40 @@ func (n *Notifier) Name() string { return "slack" }

// Notify dispatches to notifyFiring or notifyResolved based on f.Status.
func (n *Notifier) Notify(ctx context.Context, f notify.Finding) error {
unlock := n.lockCardWrite(f.IncidentID)
defer unlock()
if f.Status == "resolved" {
n.cancelPendingOcc(f.IncidentID)
return n.notifyResolved(ctx, f)
}
return n.notifyFiring(ctx, f)
}

// lockCardWrite takes a reference-counted per-incident lock and returns its
// unlock function. Entries disappear when the final holder/waiter leaves, so
// long-lived agents do not retain one mutex for every incident ever observed.
func (n *Notifier) lockCardWrite(incidentID string) func() {
n.cardWritesMu.Lock()
state := n.cardWrites[incidentID]
if state == nil {
state = &cardWriteState{}
n.cardWrites[incidentID] = state
}
state.refs++
n.cardWritesMu.Unlock()

state.mu.Lock()
return func() {
state.mu.Unlock()
n.cardWritesMu.Lock()
state.refs--
if state.refs == 0 {
delete(n.cardWrites, incidentID)
}
n.cardWritesMu.Unlock()
}
}

func (n *Notifier) notifyFiring(ctx context.Context, f notify.Finding) error {
// A recorded thread means the incident already has a card: this Notify is a
// re-judgment update, not a first firing. Edit the existing card in place
Expand Down
23 changes: 12 additions & 11 deletions internal/store/occurrences.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ func (s *Store) InsertOccurrence(ctx context.Context, occ Occurrence) (int64, er
// occurrence row whose alert never became a member (which a redelivery would
// then re-count as a fresh episode). It mirrors AddAlertToIncident's counter
// logic: the alert_count / last_alert_at bump runs only when the membership row
// is newly inserted. Returns the new occurrence id.
// is newly inserted. A new episode also reopens a resolved incident so its next
// full recovery can win the normal one-shot resolved transition and notify.
// Returns the new occurrence id.
func (s *Store) InsertOccurrenceAndAttach(ctx context.Context, occ Occurrence, alertID string, alertTime time.Time) (int64, error) {
if alertID == "" {
return 0, errors.New("store: occurrence: alert_id is required")
Expand Down Expand Up @@ -174,16 +176,15 @@ func (s *Store) InsertOccurrenceAndAttach(ctx context.Context, occ Occurrence, a
if err != nil {
return 0, fmt.Errorf("store: attach occurrence alert rows: %w", err)
}
if inserted > 0 {
if _, err := tx.ExecContext(ctx, `
UPDATE incidents
SET alert_count = alert_count + 1,
last_alert_at = MAX(last_alert_at, ?),
updated_at = ?
WHERE id = ?
`, alertTime.UTC().Format(time.RFC3339Nano), now, occ.IncidentID); err != nil {
return 0, fmt.Errorf("store: attach occurrence alert_count: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE incidents
SET alert_count = alert_count + ?,
last_alert_at = CASE WHEN ? > 0 THEN MAX(last_alert_at, ?) ELSE last_alert_at END,
status = CASE WHEN status = 'resolved' THEN 'analyzed' ELSE status END,
updated_at = ?
WHERE id = ?
`, inserted, inserted, alertTime.UTC().Format(time.RFC3339Nano), now, occ.IncidentID); err != nil {
return 0, fmt.Errorf("store: update incident for occurrence: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("store: commit occurrence tx: %w", err)
Expand Down
Loading