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
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user