Files
silo-server/internal/auth/api_key_last_used.go
e56a1b3e03 fix(api): throttle api_keys last_used_at writes in auth middleware (#381)
* fix(api): throttle api_keys last_used_at writes in auth middleware

Every API key request spawned a goroutine that ran an UPDATE on
api_keys, so a key driving HLS segments or a polling integration hit the
table with one write per request, and a stalled database could pile
those goroutines up without bound. The jellycompat authenticator already
guards this same write with a once-per-minute throttle per key; the main
middleware was missing it.

Bring the two in line. Track the last write per key ID and only launch
the update once a minute has passed, with a timeout on the background
write. The map is keyed by key ID so it stays bounded.

* fix(auth): bound API key last-used throttling

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-20 11:20:50 -04:00

81 lines
2.0 KiB
Go

package auth
import (
"context"
"log/slog"
"sync"
"time"
)
const (
apiKeyLastUsedInterval = time.Minute
apiKeyLastUsedTimeout = 5 * time.Second
)
// APIKeyLastUsedUpdater persists an API key's last-used timestamp.
type APIKeyLastUsedUpdater interface {
UpdateLastUsed(ctx context.Context, id int64) error
}
// APIKeyLastUsedTracker coalesces last-used writes per key without blocking
// authentication requests. Stale entries are pruned inline once per interval,
// avoiding both unbounded historical-key retention and another goroutine.
type APIKeyLastUsedTracker struct {
updater APIKeyLastUsedUpdater
now func() time.Time
mu sync.Mutex
lastUsedAt map[int64]time.Time
nextPrune time.Time
}
// NewAPIKeyLastUsedTracker creates a tracker. A nil clock uses time.Now.
func NewAPIKeyLastUsedTracker(updater APIKeyLastUsedUpdater, now func() time.Time) *APIKeyLastUsedTracker {
if now == nil {
now = time.Now
}
return &APIKeyLastUsedTracker{
updater: updater,
now: now,
lastUsedAt: make(map[int64]time.Time),
}
}
// Touch records key usage asynchronously, at most once per interval per key.
func (t *APIKeyLastUsedTracker) Touch(id int64) {
if t == nil || t.updater == nil || !t.shouldUpdate(id) {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), apiKeyLastUsedTimeout)
defer cancel()
if err := t.updater.UpdateLastUsed(ctx, id); err != nil {
slog.DebugContext(ctx, "api key last-used update failed", "component", "auth", "id", id, "error", err)
}
}()
}
func (t *APIKeyLastUsedTracker) shouldUpdate(id int64) bool {
now := t.now()
t.mu.Lock()
defer t.mu.Unlock()
if t.nextPrune.IsZero() || !now.Before(t.nextPrune) {
cutoff := now.Add(-apiKeyLastUsedInterval)
for keyID, lastUsed := range t.lastUsedAt {
if !lastUsed.After(cutoff) {
delete(t.lastUsedAt, keyID)
}
}
t.nextPrune = now.Add(apiKeyLastUsedInterval)
}
if lastUsed, ok := t.lastUsedAt[id]; ok && now.Sub(lastUsed) < apiKeyLastUsedInterval {
return false
}
t.lastUsedAt[id] = now
return true
}