Files
Duy /zuey/ ef829c1ab4 fix(bitrix24): resolve PR 8-10 stack findings
Resolve the PR #8/#9/#10 stack on current dev, including Bitrix24 install callback hardening, migration renumbering, duplicate-domain fail-closed routing, UI textarea/mobile cleanup, and review hardening.
2026-05-22 20:53:01 +07:00

161 lines
3.7 KiB
Go

package bitrix24
import (
"container/list"
"sync"
"time"
)
// dedupCache is a bounded LRU with per-entry TTL.
//
// Used by the webhook router to guarantee at-most-once event delivery on top
// of Bitrix24's at-least-once retry behaviour. Bitrix retries non-2xx up to
// three times; without dedup every retry would spawn a fresh agent pipeline
// run and double-bill tokens.
//
// Thread-safe. All public methods take an internal mutex.
//
// Eviction policy:
// - Once `size > maxSize` the oldest entry is evicted immediately on insert.
// - A background sweeper also removes entries whose TTL has elapsed; the
// sweeper is optional — if ttl==0 entries never expire on time, only on
// LRU pressure.
type dedupCache struct {
mu sync.Mutex
seen map[string]*list.Element
order *list.List
maxSize int
ttl time.Duration
now func() time.Time
stopCh chan struct{}
stopped bool
}
type dedupEntry struct {
key string
addedAt time.Time
}
// newDedupCache builds a cache with the given capacity and TTL.
// ttl<=0 disables time-based eviction; entries are only purged when the
// cache fills up beyond maxSize.
func newDedupCache(maxSize int, ttl time.Duration) *dedupCache {
if maxSize <= 0 {
maxSize = 1
}
return &dedupCache{
seen: make(map[string]*list.Element, maxSize),
order: list.New(),
maxSize: maxSize,
ttl: ttl,
now: time.Now,
stopCh: make(chan struct{}),
}
}
// Seen returns true iff the key has already been observed inside its TTL.
// On the first sighting (or if the prior sighting aged out) it records the
// key and returns false.
//
// The semantics are "test-and-set": callers don't need to call a separate
// Mark method after a miss; Seen does it atomically under the lock.
func (d *dedupCache) Seen(key string) bool {
if key == "" {
return false
}
d.mu.Lock()
defer d.mu.Unlock()
now := d.now()
if el, ok := d.seen[key]; ok {
ent := el.Value.(*dedupEntry)
// TTL-aged entry: treat as unseen and refresh in place.
if d.ttl > 0 && now.Sub(ent.addedAt) >= d.ttl {
ent.addedAt = now
d.order.MoveToFront(el)
return false
}
// Move to front to extend LRU residency.
d.order.MoveToFront(el)
return true
}
el := d.order.PushFront(&dedupEntry{key: key, addedAt: now})
d.seen[key] = el
// Enforce capacity after insert.
for d.order.Len() > d.maxSize {
oldest := d.order.Back()
if oldest == nil {
break
}
d.order.Remove(oldest)
delete(d.seen, oldest.Value.(*dedupEntry).key)
}
return false
}
// Len returns the current number of entries. Test helper.
func (d *dedupCache) Len() int {
d.mu.Lock()
defer d.mu.Unlock()
return d.order.Len()
}
// sweepExpired walks the oldest entries and removes anything past TTL.
// Called from the background goroutine — noop if ttl<=0.
func (d *dedupCache) sweepExpired() {
if d.ttl <= 0 {
return
}
d.mu.Lock()
defer d.mu.Unlock()
now := d.now()
for {
el := d.order.Back()
if el == nil {
return
}
ent := el.Value.(*dedupEntry)
if now.Sub(ent.addedAt) < d.ttl {
return
}
d.order.Remove(el)
delete(d.seen, ent.key)
}
}
// StartSweeper launches a goroutine that runs sweepExpired at interval.
// Safe to call once; subsequent calls are no-ops. Stop via close(cache.stopCh)
// or by calling Stop().
func (d *dedupCache) StartSweeper(interval time.Duration) {
if d.ttl <= 0 || interval <= 0 {
return
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-d.stopCh:
return
case <-t.C:
d.sweepExpired()
}
}
}()
}
// Stop terminates the background sweeper. Idempotent.
func (d *dedupCache) Stop() {
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
d.stopped = true
close(d.stopCh)
}