feat(canary): event.CountActiveDedup + token.DeleteByManageID for manage page

Adds the two service primitives Phase 11's manage handler needs.

- event.Service.CountActiveDedup(ctx, tokenID): Redis SCAN over
  dedup:trigger:{tokenID}:* in batches of 100, sums (value-1) for each
  key (value=1 = first trigger fired notification, value=N>1 = N-1
  silenced). Returns 0 when no rdb is configured.
- token.Service.DeleteByManageID: thin wrapper around
  Repository.DeleteByManageID; ServiceRepository interface gains the
  method so test fakes can implement it (fakeRepo updated). Returns
  ErrNotFound on no-rows.

Tests cover: empty pattern returns 0, first-trigger-only returns 0
(notify fired, nothing silenced), aggregation across IPs sums correctly,
other tokens' keys are excluded by the prefix match, nil rdb returns 0.
This commit is contained in:
CarterPerez-dev 2026-05-14 01:06:45 -04:00
parent 2041c557ad
commit e3f9ff48f9
4 changed files with 181 additions and 0 deletions

View File

@ -5,8 +5,10 @@ package event
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"time"
"github.com/redis/go-redis/v9"
@ -15,6 +17,7 @@ import (
const (
dedupKeyPrefix = "dedup:trigger:"
defaultDedupTTL = 15 * time.Minute
dedupScanBatch = 100
)
type Service struct {
@ -118,6 +121,49 @@ func (s *Service) dedupGate(
return false
}
func (s *Service) CountActiveDedup(
ctx context.Context,
tokenID string,
) (int64, error) {
if s.rdb == nil {
return 0, nil
}
pattern := dedupKeyPrefix + tokenID + ":*"
var total int64
var cursor uint64
for {
keys, next, err := s.rdb.Scan(
ctx, cursor, pattern, dedupScanBatch,
).Result()
if err != nil {
return 0, fmt.Errorf("dedup scan: %w", err)
}
for _, key := range keys {
v, gErr := s.rdb.Get(ctx, key).Result()
if errors.Is(gErr, redis.Nil) {
continue
}
if gErr != nil {
s.logger.WarnContext(ctx, "dedup count: get key",
"error", gErr, "key", key)
continue
}
n, pErr := strconv.ParseInt(v, 10, 64)
if pErr != nil {
continue
}
if n > 1 {
total += n - 1
}
}
if next == 0 {
break
}
cursor = next
}
return total, nil
}
func (s *Service) RunRetentionLoop(
ctx context.Context,
interval time.Duration,

View File

@ -487,6 +487,118 @@ func TestService_Record_ConcurrentSafe(t *testing.T) {
require.Equal(t, n, notifier.callCount())
}
func TestService_CountActiveDedup_NoKeysReturnsZero(t *testing.T) {
t.Parallel()
rdb, _ := setupRedis(t)
svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil)
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(0), n)
}
func TestService_CountActiveDedup_FirstTriggerOnlyCountsZero(t *testing.T) {
t.Parallel()
rdb, _ := setupRedis(t)
svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(0), n,
"first trigger fires the notification; nothing silenced yet")
}
func TestService_CountActiveDedup_CountsSilencedAcrossIPs(t *testing.T) {
t.Parallel()
rdb, _ := setupRedis(t)
svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil)
for range 3 {
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
}
for range 5 {
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.2"),
),
)
}
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(2+4), n,
"key1=3 (silenced 2) + key2=5 (silenced 4)")
}
func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) {
t.Parallel()
rdb, _ := setupRedis(t)
svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil)
for range 3 {
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
}
otherInfo := sampleInfo()
otherInfo.TokenID = "tokother0001"
for range 4 {
require.NoError(
t,
svc.Record(
context.Background(),
otherInfo,
&event.Event{TokenID: "tokother0001", SourceIP: "203.0.113.5"},
),
)
}
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(2), n, "only this token's keys counted")
}
func TestService_CountActiveDedup_NilRedisReturnsZero(t *testing.T) {
t.Parallel()
svc := event.NewService(
&fakeStore{},
&fakeIncrementer{},
nil,
nil,
event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)),
},
)
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(0), n)
}
func TestService_RunRetentionLoop_PrunesAtInterval(t *testing.T) {
t.Parallel()
store := &fakeStore{pruneCount: 5}

View File

@ -45,6 +45,7 @@ type ServiceRepository interface {
Insert(ctx context.Context, t *Token) error
GetByID(ctx context.Context, id string) (*Token, error)
GetByManageID(ctx context.Context, manageID string) (*Token, error)
DeleteByManageID(ctx context.Context, manageID string) error
IncrementTriggerCount(ctx context.Context, id string) error
}
@ -181,6 +182,13 @@ func (s *Service) IncrementTriggerCount(
return s.repo.IncrementTriggerCount(ctx, id)
}
func (s *Service) DeleteByManageID(
ctx context.Context,
manageID string,
) error {
return s.repo.DeleteByManageID(ctx, manageID)
}
func (s *Service) TriggerURL(id string) string {
return s.baseURL + "/c/" + id
}

View File

@ -73,6 +73,21 @@ func (f *fakeRepo) IncrementTriggerCount(_ context.Context, _ string) error {
return nil
}
func (f *fakeRepo) DeleteByManageID(
_ context.Context,
manageID string,
) error {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.byManage[manageID]
if !ok {
return token.ErrNotFound
}
delete(f.byManage, manageID)
delete(f.byID, t.ID)
return nil
}
type fakeGenerator struct {
tokenType token.Type
artifact generators.Artifact