Files
notifier/internal/service/service_race_test.go
T
igodwin eda033ff9b
CI / Lint (push) Successful in 2m29s
Build and Publish Container / build-and-publish (push) Successful in 2m58s
CI / Vulnerability scan (push) Successful in 44s
CI / Test (push) Successful in 1m45s
fix: clear golangci-lint backlog and make lint job blocking
Addresses errcheck, gosec, revive, staticcheck, and unused findings
across the codebase (unchecked error returns, unsafe file inclusion
warnings on operator/test-controlled paths, missing package comments,
unused parameters, deprecated API usage). Also fixes two suppression
comments that were silently no-ops due to wrong syntax (#nosec needs
a leading '#', nolint reasons need '//' not '--').

With the backlog clear, drop continue-on-error from the CI lint job
per the plan left in b4b4806.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 10:32:51 -07:00

142 lines
3.7 KiB
Go

package service
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/igodwin/notifier/internal/domain"
)
// TestConcurrentSendGetListNoRace hammers Send, GetNotification, and
// ListNotifications concurrently while worker goroutines process notifications
// pulled from the queue in the background. It is meant to be run with
// `go test -race`: if the service ever stored or returned a raw pointer that a
// worker also mutates (the bug this test guards against), the race detector
// flags a data race here.
func TestConcurrentSendGetListNoRace(t *testing.T) {
svc := createTestService(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer func() { _ = svc.Stop() }()
const numSenders = 8
const sendsPerSender = 25
var idsMu sync.Mutex
var ids []string
var sendersWg sync.WaitGroup
for s := 0; s < numSenders; s++ {
sendersWg.Add(1)
go func(sender int) {
defer sendersWg.Done()
for i := 0; i < sendsPerSender; i++ {
notification := &domain.Notification{
ID: uuid.New().String(),
Type: domain.TypeStdout,
Status: domain.StatusPending,
Subject: fmt.Sprintf("race-test-%d-%d", sender, i),
Body: "race test body",
Recipients: []string{"race@example.com"},
CC: []string{"cc@example.com"},
Metadata: map[string]interface{}{"sender": sender},
CreatedAt: time.Now(),
MaxRetries: 1,
}
if _, err := svc.Send(ctx, notification); err != nil {
t.Errorf("Send failed: %v", err)
continue
}
idsMu.Lock()
ids = append(ids, notification.ID)
idsMu.Unlock()
}
}(s)
}
// Readers race against the senders and against the worker pool mutating
// notifications as they're processed.
stopReaders := make(chan struct{})
var readersWg sync.WaitGroup
for r := 0; r < 4; r++ {
readersWg.Add(1)
go func() {
defer readersWg.Done()
for {
select {
case <-stopReaders:
return
default:
}
idsMu.Lock()
n := len(ids)
var id string
if n > 0 {
id = ids[n-1]
}
idsMu.Unlock()
if id != "" {
if notif, err := svc.GetNotification(ctx, id); err == nil {
// Touch the returned notification's reference fields;
// if it were aliased with the stored/queued copy a
// concurrent worker mutation would trip the race
// detector right here.
_ = notif.Status
_ = append([]string(nil), notif.Recipients...)
}
}
list, err := svc.ListNotifications(ctx, &domain.NotificationFilter{})
if err != nil {
t.Errorf("ListNotifications failed: %v", err)
continue
}
for _, notif := range list {
_ = notif.Status
_ = append([]string(nil), notif.Recipients...)
}
}
}()
}
sendersWg.Wait()
close(stopReaders)
readersWg.Wait()
// Give the worker pool a moment to drain the queue, then sanity check the
// service is still consistent.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
stats, err := svc.GetStats(ctx)
if err != nil {
t.Fatalf("GetStats failed: %v", err)
}
if stats.TotalSent == numSenders*sendsPerSender {
return
}
time.Sleep(20 * time.Millisecond)
}
stats, err := svc.GetStats(ctx)
if err != nil {
t.Fatalf("GetStats failed: %v", err)
}
t.Logf("final stats: sent=%d failed=%d queued=%d pending=%d", stats.TotalSent, stats.TotalFailed, stats.TotalQueued, stats.TotalPending)
if stats.TotalSent != numSenders*sendsPerSender {
t.Errorf("expected all %d notifications to reach Sent, got %d", numSenders*sendsPerSender, stats.TotalSent)
}
}