perf(admin): cache the suspension gate lookup
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s

The suspension gate runs CurrentSuspension on every authenticated request. Add a write-through in-memory cache on account.Store keyed by account id, invalidated on Suspend/LiftSuspension, with the cached entry re-evaluated against the wall clock so a temporary block lapses without an explicit invalidation. Single-instance, matching the deployment (one shared Store). Keeps the gate off the database on the hot path while a block still takes effect on the next request.
This commit is contained in:
Ilia Denisov
2026-06-14 22:24:57 +02:00
parent d1ba666495
commit 290874720f
3 changed files with 138 additions and 1 deletions
+82
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/go-jet/jet/v2/postgres"
@@ -82,6 +83,7 @@ func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Ti
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err)
}
s.invalidateSuspension(accountID)
return modelToSuspension(row), nil
}
@@ -100,6 +102,7 @@ func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error {
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: lift suspension %s: %w", accountID, err)
}
s.invalidateSuspension(accountID)
return nil
}
@@ -115,6 +118,30 @@ func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Sus
return Suspension{}, false, nil
}
now := time.Now().UTC()
if s.suspensions != nil {
if e, ok := s.suspensions.get(accountID); ok {
if !e.found {
return Suspension{}, false, nil // cached: not blocked
}
if suspensionActiveAt(e.susp, now) {
return e.susp, true, nil // cached block still in force
}
// The cached block has lapsed since it was cached; refresh from the database below.
}
}
susp, found, err := s.queryCurrentSuspension(ctx, accountID, now)
if err != nil {
return Suspension{}, false, err
}
if s.suspensions != nil {
s.suspensions.put(accountID, suspensionCacheEntry{susp: susp, found: found})
}
return susp, found, nil
}
// queryCurrentSuspension reads the account's strongest in-force block straight from the database
// (no cache), as of now. It backs CurrentSuspension on a cache miss or a lapsed entry.
func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID, now time.Time) (Suspension, bool, error) {
stmt := postgres.SELECT(table.AccountSuspensions.AllColumns).
FROM(table.AccountSuspensions).
WHERE(
@@ -134,6 +161,61 @@ func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Sus
return modelToSuspension(row), true, nil
}
// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
// Called after Suspend and LiftSuspension.
func (s *Store) invalidateSuspension(accountID uuid.UUID) {
if s.suspensions != nil {
s.suspensions.invalidate(accountID)
}
}
// suspensionActiveAt reports whether a suspension is in force at now: permanent, or not yet
// expired. The lifted check is implicit — only non-lifted blocks are ever cached or returned.
func suspensionActiveAt(susp Suspension, now time.Time) bool {
return susp.BlockedUntil == nil || susp.BlockedUntil.After(now)
}
// suspensionCache is the gate's write-through cache of each account's current block, keyed by
// account id. The suspension gate reads it on every authenticated request; Suspend and
// LiftSuspension invalidate the account's entry. An entry holds the strongest active block at
// query time (or a not-blocked marker), re-evaluated against the wall clock on read, so a
// temporary block lapses without an explicit invalidation. It is single-instance, matching the
// deployment (one shared Store); a multi-instance deployment would need a shared cache.
type suspensionCache struct {
mu sync.RWMutex
m map[uuid.UUID]suspensionCacheEntry
}
// suspensionCacheEntry is a cached lookup: the strongest active block when found, else a
// not-blocked marker (found=false).
type suspensionCacheEntry struct {
susp Suspension
found bool
}
func newSuspensionCache() *suspensionCache {
return &suspensionCache{m: make(map[uuid.UUID]suspensionCacheEntry)}
}
func (c *suspensionCache) get(id uuid.UUID) (suspensionCacheEntry, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.m[id]
return e, ok
}
func (c *suspensionCache) put(id uuid.UUID, e suspensionCacheEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[id] = e
}
func (c *suspensionCache) invalidate(id uuid.UUID) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.m, id)
}
// activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and
// either permanent or not yet expired.
func activeSuspensionPredicate(now time.Time) postgres.BoolExpression {