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() } } }