Go · 2141 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package pat
4
5 import (
6 "sync"
7 "time"
8 )
9
10 // Debouncer suppresses repeat last-used updates for a given token within
11 // a time window. The auth middleware calls ShouldTouch before issuing
12 // the DB write so 100 requests/sec hitting the same token don't burn
13 // 100 UPDATEs on user_tokens.
14 //
15 // Eviction is a simple cap-based sweep: once the map exceeds maxEntries,
16 // we drop entries older than the window. Acceptable for our scale —
17 // bounded by the active-PAT count, not the request rate.
18 type Debouncer struct {
19 mu sync.Mutex
20 last map[int64]time.Time
21 window time.Duration
22 maxEntries int
23 now func() time.Time
24 }
25
26 // NewDebouncer returns a Debouncer with a 60-second default window and a
27 // 4096-entry cap. The defaults are right for our launch scale; expose as
28 // fields (NOT as constructor args) if a test needs to override them.
29 func NewDebouncer(window time.Duration) *Debouncer {
30 if window <= 0 {
31 window = 60 * time.Second
32 }
33 return &Debouncer{
34 last: make(map[int64]time.Time),
35 window: window,
36 maxEntries: 4096,
37 now: time.Now,
38 }
39 }
40
41 // ShouldTouch reports whether the auth middleware should issue a
42 // last-used DB write for tokenID right now. Returns true at most once per
43 // (tokenID, window) pair — subsequent calls within the window return
44 // false. Always thread-safe.
45 func (d *Debouncer) ShouldTouch(tokenID int64) bool {
46 now := d.now()
47 d.mu.Lock()
48 defer d.mu.Unlock()
49 if last, ok := d.last[tokenID]; ok && now.Sub(last) < d.window {
50 return false
51 }
52 d.last[tokenID] = now
53 if len(d.last) > d.maxEntries {
54 d.evictStaleLocked(now)
55 }
56 return true
57 }
58
59 func (d *Debouncer) evictStaleLocked(now time.Time) {
60 for k, t := range d.last {
61 if now.Sub(t) >= d.window {
62 delete(d.last, k)
63 }
64 }
65 }
66
67 // Forget removes tokenID from the debouncer's memory — useful when a
68 // token is revoked, so a subsequent (unauthenticated) lookup with the
69 // same id can't accidentally inherit a stale "recently touched" state.
70 func (d *Debouncer) Forget(tokenID int64) {
71 d.mu.Lock()
72 defer d.mu.Unlock()
73 delete(d.last, tokenID)
74 }
75