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:
parent
2041c557ad
commit
e3f9ff48f9
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue