Files
Курнат Андрей 2315f25754 Initial QSfera import
2026-06-07 10:20:04 +03:00

164 lines
3.6 KiB
Go

package lh
import (
"sync"
"time"
"github.com/olekukonko/ll/lx"
)
// Dedup is a log handler that suppresses duplicate entries within a TTL window.
// It wraps another handler (H) and filters out repeated log entries that match
// within the deduplication period.
type Dedup[H lx.Handler] struct {
next H
ttl time.Duration
cleanupEvery time.Duration
keyFn func(*lx.Entry) uint64
maxKeys int
// shards reduce lock contention by partitioning the key space
shards [32]dedupShard
done chan struct{}
wg sync.WaitGroup
once sync.Once
}
type dedupShard struct {
mu sync.Mutex
seen map[uint64]int64
}
// DedupOpt configures a Dedup handler.
type DedupOpt[H lx.Handler] func(*Dedup[H])
// WithDedupKeyFunc customizes how deduplication keys are generated.
func WithDedupKeyFunc[H lx.Handler](fn func(*lx.Entry) uint64) DedupOpt[H] {
return func(d *Dedup[H]) { d.keyFn = fn }
}
// WithDedupCleanupInterval sets how often expired deduplication keys are purged.
func WithDedupCleanupInterval[H lx.Handler](every time.Duration) DedupOpt[H] {
return func(d *Dedup[H]) {
if every > 0 {
d.cleanupEvery = every
}
}
}
// WithDedupMaxKeys sets a soft limit on tracked deduplication keys.
func WithDedupMaxKeys[H lx.Handler](max int) DedupOpt[H] {
return func(d *Dedup[H]) {
if max > 0 {
d.maxKeys = max
}
}
}
// NewDedup creates a deduplicating handler wrapper.
func NewDedup[H lx.Handler](next H, ttl time.Duration, opts ...DedupOpt[H]) *Dedup[H] {
if ttl <= 0 {
ttl = 2 * time.Second
}
d := &Dedup[H]{
next: next,
ttl: ttl,
cleanupEvery: time.Minute,
keyFn: defaultDedupKey,
done: make(chan struct{}),
}
// Initialize shards
for i := 0; i < len(d.shards); i++ {
d.shards[i].seen = make(map[uint64]int64, 64)
}
for _, opt := range opts {
opt(d)
}
d.wg.Add(1)
go d.cleanupLoop()
return d
}
// Handle processes a log entry, suppressing duplicates within the TTL window.
func (d *Dedup[H]) Handle(e *lx.Entry) error {
now := time.Now().UnixNano()
key := d.keyFn(e)
// Select shard based on key hash
shardIdx := key % uint64(len(d.shards))
shard := &d.shards[shardIdx]
shard.mu.Lock()
exp, ok := shard.seen[key]
if ok && now < exp {
shard.mu.Unlock()
return nil
}
// Basic guard against unbounded growth per shard
// Using strict limits per shard avoids global atomic counters
limitPerShard := d.maxKeys / len(d.shards)
if d.maxKeys > 0 && len(shard.seen) >= limitPerShard {
// Opportunistic cleanup of current shard
d.cleanupShard(shard, now)
}
shard.seen[key] = now + d.ttl.Nanoseconds()
shard.mu.Unlock()
return d.next.Handle(e)
}
// Close stops the cleanup goroutine and closes the underlying handler.
func (d *Dedup[H]) Close() error {
var err error
d.once.Do(func() {
close(d.done)
d.wg.Wait()
if c, ok := any(d.next).(interface{ Close() error }); ok {
err = c.Close()
}
})
return err
}
// cleanupLoop runs periodically to purge expired deduplication keys.
func (d *Dedup[H]) cleanupLoop() {
defer d.wg.Done()
t := time.NewTicker(d.cleanupEvery)
defer t.Stop()
for {
select {
case <-t.C:
now := time.Now().UnixNano()
// Cleanup all shards sequentially to avoid massive CPU spike
for i := 0; i < len(d.shards); i++ {
d.shards[i].mu.Lock()
d.cleanupShard(&d.shards[i], now)
d.shards[i].mu.Unlock()
}
case <-d.done:
return
}
}
}
// cleanupShard removes expired keys from a specific shard.
func (d *Dedup[H]) cleanupShard(shard *dedupShard, now int64) {
for k, exp := range shard.seen {
if now > exp {
delete(shard.seen, k)
}
}
}