Files
Snip/backend/internal/service/clicks.go
T
2026-06-15 21:25:57 +07:00

96 lines
2.0 KiB
Go

package service
import (
"context"
"sync"
"time"
"github.com/snip/backend/internal/port"
)
// AsyncClickRecorder coalesces clicks in memory and flushes them to the
// repository in batches. This keeps the redirect path free of synchronous DB
// writes and collapses bursts (e.g. 1000 clicks/sec on one code) into a single
// periodic UPDATE.
type AsyncClickRecorder struct {
repo port.LinkRepository
interval time.Duration
mu sync.Mutex
pending map[string]int64
stop chan struct{}
done chan struct{}
}
func NewAsyncClickRecorder(repo port.LinkRepository, interval time.Duration) *AsyncClickRecorder {
if interval <= 0 {
interval = 10 * time.Second
}
return &AsyncClickRecorder{
repo: repo,
interval: interval,
pending: make(map[string]int64),
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Record is non-blocking: it just bumps an in-memory counter.
func (r *AsyncClickRecorder) Record(code string) {
r.mu.Lock()
r.pending[code]++
r.mu.Unlock()
}
// Start runs the flush loop until ctx is cancelled or Stop is called.
func (r *AsyncClickRecorder) Start(ctx context.Context) {
go func() {
defer close(r.done)
t := time.NewTicker(r.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
r.flush(context.Background())
return
case <-r.stop:
r.flush(context.Background())
return
case <-t.C:
r.flush(ctx)
}
}
}()
}
// Stop flushes and halts the loop.
func (r *AsyncClickRecorder) Stop() {
select {
case <-r.stop:
default:
close(r.stop)
}
<-r.done
}
func (r *AsyncClickRecorder) flush(ctx context.Context) {
r.mu.Lock()
batch := r.pending
r.pending = make(map[string]int64)
r.mu.Unlock()
if len(batch) == 0 {
return
}
day := time.Now().UTC().Truncate(24 * time.Hour)
for code, n := range batch {
if err := r.repo.RecordClicks(ctx, code, day, n); err != nil {
// Re-queue on failure so clicks aren't lost.
r.mu.Lock()
r.pending[code] += n
r.mu.Unlock()
}
}
}