Add the enforcement layer on top of server-observed monitoring: a revocation kill switch that stops any stream within ~120s and keeps it dead, plus an async over-cap enforcer that drives kills off the live monitoring picture — entirely off the per-segment hot path and with no client-protocol change. - internal/streamrevoke: the central kill list. IsRevoked is a pure in-memory lookup safe on the request hot path; a Redis pub/sub + poll mirror keeps edge caches current, and a Postgres durable mirror lets kills survive a server restart AND a Redis flush so a restart-resilient stream cannot be reconstructed and re-served after being killed. A user revocation is a cutoff (kills tokens minted before it, spares post-reauth tokens), not a 24h ban. - internal/streamenforcer: async over-cap brain — reads the monitoring snapshot and per-user limits, selects victims, and collapses every reason (exceeded limit, admin terminate, abuse) to the same action: write a revocation. - Edge + native + jellycompat enforcement: proxy refuses revoked sessions on every request and cuts long direct-play/remux pours mid-stream; the transcode node guards both serve and the reconstruct path so a killed session is never re-spawned after a node restart; jellycompat serve surfaces close their kill-switch coverage holes. - streamtoken.IssuedTime exposes the token iat the user-kill cutoff compares against; token IssuedTime + revocation guards wire through router, downloads, and admin terminate-by-id (with admin-list dedupe). - Restore sendfile zero-copy on direct-play/remux byte counting so the monitor's served-byte accounting does not cost the sendfile fast path. - migrations/sql: stream_revocations durable table. Part of the stream monitoring & kill-switch epic.
587 lines
21 KiB
Go
587 lines
21 KiB
Go
// Package streamrevoke implements a stream-revocation "kill switch": a small
|
|
// set of revoked stream sessions/users that edge nodes consult on the hot path
|
|
// via an in-memory cache, backed by Redis for multi-node propagation with an
|
|
// optional durable Postgres mirror.
|
|
//
|
|
// The hot path (IsRevoked) is a pure in-memory read with no I/O. Revocations
|
|
// are propagated to other nodes over Redis pub/sub for immediate application
|
|
// and mirrored to Redis keys (with a TTL) so late-joining or restarting nodes
|
|
// can reconcile via a periodic SCAN. A durable mirror, when configured, lets
|
|
// kills survive a Redis flush.
|
|
package streamrevoke
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/cache"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
const (
|
|
// keyPrefix is the Redis key namespace for revocation mirror keys, e.g.
|
|
// silo:revoked:sess:{id} and silo:revoked:user:{id}.
|
|
keyPrefix = "silo:revoked:"
|
|
|
|
// scanPattern matches every revocation mirror key for cache warming and
|
|
// periodic reconciliation.
|
|
scanPattern = keyPrefix + "*"
|
|
|
|
// EventStreamRevoked is the cache.Event.Type published on cache.ChannelAdmin
|
|
// when a revocation is created so other nodes apply it immediately.
|
|
EventStreamRevoked = "stream_revoked"
|
|
|
|
defaultPollInterval = 60 * time.Second
|
|
// defaultTTL is intentionally >= the playback recipe-card lifetime
|
|
// (playback.MaxTokenTTL, 24h): a kill must never expire before the session it
|
|
// kills can be reconstructed, or PR #174's restart-resilient playback could
|
|
// rebuild and re-serve a stream whose revocation had already lapsed. This is
|
|
// an invariant, stated in words to avoid importing the playback package here
|
|
// (which would create an import cycle); keep the two values coupled.
|
|
defaultTTL = 24 * time.Hour
|
|
)
|
|
|
|
// Kind identifies what a revocation targets.
|
|
type Kind string
|
|
|
|
const (
|
|
// KindSession revokes a single stream session id.
|
|
KindSession Kind = "sess"
|
|
// KindUser revokes every stream belonging to a user id.
|
|
KindUser Kind = "user"
|
|
)
|
|
|
|
// Key uniquely identifies a revocation in the in-memory cache and Redis.
|
|
type Key struct {
|
|
Kind Kind
|
|
ID string
|
|
}
|
|
|
|
// Revocation is a single kill-switch entry.
|
|
type Revocation struct {
|
|
Kind Kind `json:"kind"`
|
|
ID string `json:"id"`
|
|
Reason string `json:"reason,omitempty"`
|
|
RevokedAt time.Time `json:"revoked_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// key returns the in-memory cache key for this revocation.
|
|
func (r Revocation) key() Key {
|
|
return Key{Kind: r.Kind, ID: r.ID}
|
|
}
|
|
|
|
// expired reports whether the revocation is no longer in force at now.
|
|
func (r Revocation) expired(now time.Time) bool {
|
|
return !r.ExpiresAt.IsZero() && !now.Before(r.ExpiresAt)
|
|
}
|
|
|
|
// DurableStore is an optional Postgres-backed mirror so kills survive a Redis
|
|
// flush. A concrete implementation lives elsewhere; this package only consumes
|
|
// the interface when one is provided.
|
|
type DurableStore interface {
|
|
Upsert(ctx context.Context, r Revocation) error
|
|
ListActive(ctx context.Context) ([]Revocation, error)
|
|
Prune(ctx context.Context) error
|
|
}
|
|
|
|
// Options configures a Store.
|
|
type Options struct {
|
|
Redis *redis.Client // nil => memory-only (integrated single-node)
|
|
Bus cache.EventBus // nil => no push propagation
|
|
Durable DurableStore // nil => no durable mirror
|
|
PollInterval time.Duration // default 60s
|
|
DefaultTTL time.Duration // default 24h
|
|
}
|
|
|
|
// Store holds the in-memory revocation cache and its propagation plumbing.
|
|
type Store struct {
|
|
rdb *redis.Client
|
|
bus cache.EventBus
|
|
durable DurableStore
|
|
pollInterval time.Duration
|
|
defaultTTL time.Duration
|
|
|
|
mu sync.RWMutex
|
|
items map[Key]Revocation
|
|
}
|
|
|
|
// New builds a Store, applying defaults for any unset Options.
|
|
func New(opts Options) *Store {
|
|
if opts.PollInterval <= 0 {
|
|
opts.PollInterval = defaultPollInterval
|
|
}
|
|
if opts.DefaultTTL <= 0 {
|
|
opts.DefaultTTL = defaultTTL
|
|
}
|
|
return &Store{
|
|
rdb: opts.Redis,
|
|
bus: opts.Bus,
|
|
durable: opts.Durable,
|
|
pollInterval: opts.PollInterval,
|
|
defaultTTL: opts.DefaultTTL,
|
|
items: make(map[Key]Revocation),
|
|
}
|
|
}
|
|
|
|
// redisKey returns the Redis mirror key for a revocation key.
|
|
func redisKey(k Key) string {
|
|
return keyPrefix + string(k.Kind) + ":" + k.ID
|
|
}
|
|
|
|
// userKey returns the cache key for a numeric user id.
|
|
func userKey(userID int) Key {
|
|
return Key{Kind: KindUser, ID: strconv.Itoa(userID)}
|
|
}
|
|
|
|
// Refuse is the single shared enforcement point for every serve surface (edge
|
|
// proxy, transcode node, native api, jellycompat): if the session or user is
|
|
// revoked it writes a 403 and returns true, and the caller must stop serving.
|
|
// Centralizing the check + response here keeps one section per concern instead
|
|
// of duplicating "IsRevoked → 403" at each surface. It does NOT hang up an
|
|
// in-flight connection — long-pour paths pair this with a connection-cut helper.
|
|
// startedAt is when the request's stream credential was issued (see IsRevoked).
|
|
func (s *Store) Refuse(w http.ResponseWriter, sessionID string, userID int, startedAt time.Time) bool {
|
|
if s == nil || !s.IsRevoked(sessionID, userID, startedAt) {
|
|
return false
|
|
}
|
|
http.Error(w, "stream revoked", http.StatusForbidden)
|
|
return true
|
|
}
|
|
|
|
// WatchAndCut watches a long-lived pour (a single long-GET direct-play/remux) and
|
|
// once the session/user is revoked, forces the in-flight write to fail via
|
|
// SetWriteDeadline — hanging up the socket even though the 24h token is still
|
|
// valid (cutting an open connection is a socket action, not a token revocation).
|
|
// It checks immediately on entry (so a pour that began the instant before the
|
|
// kill is cut without waiting a tick) and then every 5s. Returns a stop func the
|
|
// caller defers when the request finishes normally. This is the shared in-flight
|
|
// cut used by every long-pour serve surface (edge proxy, native api,
|
|
// jellycompat), so the cut logic lives in one place. HLS/transcode paths don't
|
|
// need it — per-segment Refuse stops them within one segment.
|
|
//
|
|
// Best-effort: if the ResponseWriter chain doesn't support write deadlines the
|
|
// deadline set is a no-op and the stream still stops on its next request via
|
|
// Refuse. Never wraps the writer, so it does not disable sendfile.
|
|
// startedAt follows IsRevoked's contract; a pour in flight when a user kill
|
|
// lands always predates that kill, so passing the request's credential/entry
|
|
// time makes mid-pour user kills cut correctly on every surface.
|
|
func (s *Store) WatchAndCut(w http.ResponseWriter, sessionID string, userID int, startedAt time.Time) func() {
|
|
if s == nil {
|
|
return func() {}
|
|
}
|
|
cut := func() { _ = http.NewResponseController(w).SetWriteDeadline(time.Now()) }
|
|
if s.IsRevoked(sessionID, userID, startedAt) {
|
|
cut()
|
|
return func() {}
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
if s.IsRevoked(sessionID, userID, startedAt) {
|
|
cut()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
var once sync.Once
|
|
return func() { once.Do(func() { close(done) }) }
|
|
}
|
|
|
|
// IsRevoked is the HOT PATH: a pure in-memory cache read with no I/O. It
|
|
// returns true if the session id is currently revoked, or if the user id is
|
|
// revoked AND the stream's credential predates that user revocation.
|
|
//
|
|
// startedAt is when the request's stream credential was issued: the stream
|
|
// token's iat on token-bearing surfaces (edge proxy, transcode node, native
|
|
// ?st=), or the request entry time on freshly-authenticated surfaces (native
|
|
// session auth, jellycompat login). A user revocation is a CUTOFF, not a ban:
|
|
// it kills streams whose credential predates it, while a stream authorized
|
|
// after it (which required passing auth that the revocation just reset) plays
|
|
// normally. Without the cutoff, the OnUserSessionsRevoked hook — fired by any
|
|
// admin edit of password/role/enabled/permissions/quality — would 403 the
|
|
// user's playback for the full 24h TTL even after they re-authenticate, with
|
|
// no unrevoke path (expiry is deliberately monotonic). A zero startedAt never
|
|
// matches a user revocation (fail open, matching the enforcer's "never kill on
|
|
// uncertainty"); session revocations are exact-id kills and ignore startedAt.
|
|
func (s *Store) IsRevoked(sessionID string, userID int, startedAt time.Time) bool {
|
|
now := time.Now()
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if sessionID != "" {
|
|
if r, ok := s.items[Key{Kind: KindSession, ID: sessionID}]; ok && !r.expired(now) {
|
|
return true
|
|
}
|
|
}
|
|
// userID <= 0 is the "no resolved owner" sentinel (e.g. the transcode node's
|
|
// session-only check). Never match it against a KindUser entry: a stray
|
|
// user:"0" revocation must not read as "every ownerless request is revoked".
|
|
if userID > 0 {
|
|
if r, ok := s.items[userKey(userID)]; ok && !r.expired(now) &&
|
|
!startedAt.IsZero() && startedAt.Before(r.RevokedAt) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// effectiveExpiry returns the instant a revocation lapses, treating a zero
|
|
// ExpiresAt as "never" (far future) so monotonic comparisons order a permanent
|
|
// kill above any bounded one.
|
|
func (r Revocation) effectiveExpiry() time.Time {
|
|
if r.ExpiresAt.IsZero() {
|
|
return permanentExpiry
|
|
}
|
|
return r.ExpiresAt
|
|
}
|
|
|
|
// applyLocal inserts or refreshes a revocation in the in-memory cache, keeping
|
|
// whichever copy expires LATER. Expiry is monotonic: a re-revoke with a shorter
|
|
// TTL must never shorten a longer-lived kill. This matters because the async
|
|
// over-cap enforcer re-revokes with a short self-healing TTL (5m); without this
|
|
// guard it would silently shrink an admin's 24h RevokeSession on the same
|
|
// session key and reopen the restart-resurrection window PR #174 exposes. The
|
|
// same rule lets a mid-window Redis reconcile that reads a stale longer entry
|
|
// not fight a freshly-applied one. The caller must not hold s.mu.
|
|
func (s *Store) applyLocal(r Revocation) {
|
|
s.mu.Lock()
|
|
if existing, ok := s.items[r.key()]; !ok || !existing.effectiveExpiry().After(r.effectiveExpiry()) {
|
|
s.items[r.key()] = r
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// effective returns the currently-stored revocation for a key — the
|
|
// monotonically-merged copy applyLocal kept (whichever expires later). Zero
|
|
// value if absent.
|
|
func (s *Store) effective(k Key) Revocation {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.items[k]
|
|
}
|
|
|
|
// Revoke adds a revocation to the local cache, writes it to Redis (with a TTL
|
|
// of until-now) when configured, mirrors it to the durable store when
|
|
// configured, and publishes a pub/sub event so other nodes apply it
|
|
// immediately.
|
|
func (s *Store) Revoke(ctx context.Context, key Key, reason string, until time.Time) error {
|
|
now := time.Now()
|
|
r := Revocation{
|
|
Kind: key.Kind,
|
|
ID: key.ID,
|
|
Reason: reason,
|
|
RevokedAt: now,
|
|
ExpiresAt: until,
|
|
}
|
|
|
|
// Local cache first: the hot path must reflect the kill immediately even if
|
|
// downstream propagation fails.
|
|
s.applyLocal(r)
|
|
|
|
// Propagation must not die with the caller: an admin terminate rides its
|
|
// HTTP request's context, and an abort after applyLocal would strand the
|
|
// kill in this process's memory — never reaching Redis (edges), pub/sub, or
|
|
// the durable mirror (lost on restart).
|
|
ctx = context.WithoutCancel(ctx)
|
|
|
|
// Durable mirror (best-effort; failures are non-fatal).
|
|
if s.durable != nil {
|
|
if err := s.durable.Upsert(ctx, r); err != nil {
|
|
slog.Warn("streamrevoke durable upsert failed", "error", err, "kind", r.Kind, "id", r.ID)
|
|
}
|
|
}
|
|
|
|
// Redis mirror for multi-node propagation to edges. Mirror the merged copy
|
|
// applyLocal kept (monotonic): a short over-cap re-revoke must not shorten a
|
|
// longer admin kill's Redis TTL, matching applyLocal and the durable upsert's
|
|
// GREATEST. Edges that warm from Redis in that window must see the later kill.
|
|
s.mirrorToRedis(ctx, s.effective(r.key()))
|
|
|
|
// Pub/sub push so already-connected nodes apply the kill immediately (the
|
|
// warm loops deliberately skip this — SCAN reconcile handles late-joiners).
|
|
if s.bus != nil {
|
|
data, err := json.Marshal(r)
|
|
if err != nil {
|
|
slog.Warn("streamrevoke marshal failed", "error", err, "kind", r.Kind, "id", r.ID)
|
|
return err
|
|
}
|
|
evt := cache.Event{Type: EventStreamRevoked, Payload: string(data)}
|
|
if err := s.bus.Publish(ctx, cache.ChannelAdmin, evt); err != nil {
|
|
slog.Warn("streamrevoke publish failed", "error", err, "kind", r.Kind, "id", r.ID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// mirrorToRedis writes (or deletes) the Redis mirror key for a revocation with a
|
|
// TTL of the time remaining until r.ExpiresAt. It is the single shared
|
|
// Redis-arming path, used both by Revoke and by the durable warm loops: edge
|
|
// nodes (proxy/transcode) have no durable store and learn kills ONLY from
|
|
// Redis, so re-arming Redis from the durable source of truth after a Redis flush
|
|
// + central restart is what lets edges reconverge via their SCAN reconcile.
|
|
// Best-effort: failures are logged, never returned. No-op when Redis is absent.
|
|
func (s *Store) mirrorToRedis(ctx context.Context, r Revocation) {
|
|
if s.rdb == nil {
|
|
return
|
|
}
|
|
data, err := json.Marshal(r)
|
|
if err != nil {
|
|
slog.Warn("streamrevoke marshal failed", "error", err, "kind", r.Kind, "id", r.ID)
|
|
return
|
|
}
|
|
if r.ExpiresAt.IsZero() {
|
|
// Permanent kill: set without a TTL, matching applyLocal/effectiveExpiry
|
|
// treating a zero ExpiresAt as "never". A TTL of time.Until(zero) would be
|
|
// hugely negative and drop the key, so late-joining edges would miss it.
|
|
if err := s.rdb.Set(ctx, redisKey(r.key()), data, 0).Err(); err != nil {
|
|
slog.Warn("streamrevoke redis set failed", "error", err, "kind", r.Kind, "id", r.ID)
|
|
}
|
|
return
|
|
}
|
|
ttl := time.Until(r.ExpiresAt)
|
|
if ttl <= 0 {
|
|
// Already expired: nothing to mirror in Redis.
|
|
if err := s.rdb.Del(ctx, redisKey(r.key())).Err(); err != nil {
|
|
slog.Debug("streamrevoke redis del failed", "error", err, "kind", r.Kind, "id", r.ID)
|
|
}
|
|
return
|
|
}
|
|
if err := s.rdb.Set(ctx, redisKey(r.key()), data, ttl).Err(); err != nil {
|
|
slog.Warn("streamrevoke redis set failed", "error", err, "kind", r.Kind, "id", r.ID)
|
|
}
|
|
}
|
|
|
|
// RevokeSession revokes a single session id for DefaultTTL.
|
|
func (s *Store) RevokeSession(ctx context.Context, sessionID, reason string) error {
|
|
return s.Revoke(ctx, Key{Kind: KindSession, ID: sessionID}, reason, time.Now().Add(s.defaultTTL))
|
|
}
|
|
|
|
// RevokeSessionFor revokes a single session id for a caller-chosen TTL. The
|
|
// async enforcer uses a short TTL so a transient over-count (e.g. a ghost
|
|
// session lingering next to a fresh reconnect) self-heals; a persistent abuser
|
|
// is simply re-revoked on the next evaluation pass. ttl <= 0 falls back to
|
|
// DefaultTTL.
|
|
func (s *Store) RevokeSessionFor(ctx context.Context, sessionID, reason string, ttl time.Duration) error {
|
|
if ttl <= 0 {
|
|
ttl = s.defaultTTL
|
|
}
|
|
return s.Revoke(ctx, Key{Kind: KindSession, ID: sessionID}, reason, time.Now().Add(ttl))
|
|
}
|
|
|
|
// RevokeUser revokes the user's streams for DefaultTTL. It is a CUTOFF, not a
|
|
// ban: enforcement (IsRevoked) only matches streams whose credential was issued
|
|
// before this revocation, so playback the user starts after re-authenticating
|
|
// is unaffected. This is what makes it safe for OnUserSessionsRevoked to call
|
|
// on every auth-session revocation (admin edits included), while still cutting
|
|
// every stream that rode a pre-revocation credential.
|
|
func (s *Store) RevokeUser(ctx context.Context, userID int, reason string) error {
|
|
// IsRevoked never matches userID <= 0 against a KindUser entry, so a kill for
|
|
// such an id would be silently ineffective. Refuse it rather than report a
|
|
// success that has no teeth.
|
|
if userID <= 0 {
|
|
return fmt.Errorf("streamrevoke: invalid userID %d", userID)
|
|
}
|
|
return s.Revoke(ctx, userKey(userID), reason, time.Now().Add(s.defaultTTL))
|
|
}
|
|
|
|
// List returns the currently-active revocations, pruning expired entries on
|
|
// read.
|
|
func (s *Store) List() []Revocation {
|
|
now := time.Now()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]Revocation, 0, len(s.items))
|
|
for k, r := range s.items {
|
|
if r.expired(now) {
|
|
delete(s.items, k)
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pruneExpired removes expired entries from the in-memory cache.
|
|
func (s *Store) pruneExpired() {
|
|
now := time.Now()
|
|
s.mu.Lock()
|
|
for k, r := range s.items {
|
|
if r.expired(now) {
|
|
delete(s.items, k)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// StartSync warms the cache (durable store then a Redis SCAN of silo:revoked:*)
|
|
// and subscribes to cache.ChannelAdmin to apply EventStreamRevoked events. The
|
|
// initial warm and subscribe run SYNCHRONOUSLY (inline) before StartSync
|
|
// returns, so kills recorded durably or in Redis are already enforced before
|
|
// the first stream is served. Only the PollInterval reconcile/prune loop runs
|
|
// in a spawned goroutine. With a nil Redis client the Store is memory-only and
|
|
// only the local prune (plus durable maintenance, if configured) runs on the
|
|
// tick.
|
|
func (s *Store) StartSync(ctx context.Context) {
|
|
// Warm from the durable mirror first so kills survive a Redis flush. Each
|
|
// non-expired entry is also re-armed into Redis (mirrorToRedis no-ops when
|
|
// Redis is absent) so edge nodes — which learn kills only from Redis —
|
|
// reconverge via their SCAN reconcile after a Redis flush + central restart.
|
|
if s.durable != nil {
|
|
if revs, err := s.warmFromDurable(ctx); err != nil {
|
|
slog.Warn("streamrevoke durable warm failed", "error", err)
|
|
} else {
|
|
now := time.Now()
|
|
for _, r := range revs {
|
|
if !r.expired(now) {
|
|
s.applyLocal(r)
|
|
s.mirrorToRedis(ctx, r)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Warm from Redis and subscribe for push updates.
|
|
if s.rdb != nil {
|
|
s.reconcileFromRedis(ctx)
|
|
}
|
|
if s.bus != nil {
|
|
if err := s.bus.Subscribe(ctx, cache.ChannelAdmin, s.handleEvent); err != nil {
|
|
slog.Warn("streamrevoke subscribe failed", "error", err)
|
|
}
|
|
}
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(s.pollInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.maintain(ctx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// warmFromDurable loads the active revocations for the boot-time cache warm,
|
|
// retrying a bounded number of times with short backoff. A single transient DB
|
|
// error at startup (with Redis also flushed) would otherwise leave the kill
|
|
// list empty until the first poll tick 60s later; the retry closes that window.
|
|
// It is strictly bounded and honors ctx cancellation, so startup never blocks
|
|
// indefinitely. Only the boot warm needs this — the recurring maintain tick is
|
|
// its own backstop.
|
|
func (s *Store) warmFromDurable(ctx context.Context) ([]Revocation, error) {
|
|
backoffs := []time.Duration{200 * time.Millisecond, 400 * time.Millisecond}
|
|
var revs []Revocation
|
|
var err error
|
|
for attempt := 0; ; attempt++ {
|
|
if revs, err = s.durable.ListActive(ctx); err == nil {
|
|
return revs, nil
|
|
}
|
|
if attempt >= len(backoffs) {
|
|
return nil, err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, err
|
|
case <-time.After(backoffs[attempt]):
|
|
}
|
|
}
|
|
}
|
|
|
|
// maintain runs one reconcile/prune pass: it is the body of the poll tick,
|
|
// factored out so tests can exercise it directly without a live ticker. All
|
|
// steps are best-effort — a failure in one is logged and does not block the
|
|
// others or the next tick.
|
|
func (s *Store) maintain(ctx context.Context) {
|
|
if s.rdb != nil {
|
|
s.reconcileFromRedis(ctx)
|
|
}
|
|
// Durable maintenance: re-warm from the durable mirror so a mid-life Redis
|
|
// flush self-heals, re-arming Redis for edge nodes too, then physically
|
|
// reclaim expired rows.
|
|
if s.durable != nil {
|
|
if revs, err := s.durable.ListActive(ctx); err != nil {
|
|
slog.Warn("streamrevoke durable reconcile failed", "error", err)
|
|
} else {
|
|
now := time.Now()
|
|
for _, r := range revs {
|
|
if !r.expired(now) {
|
|
s.applyLocal(r)
|
|
s.mirrorToRedis(ctx, r)
|
|
}
|
|
}
|
|
}
|
|
if err := s.durable.Prune(ctx); err != nil {
|
|
slog.Warn("streamrevoke durable prune failed", "error", err)
|
|
}
|
|
}
|
|
s.pruneExpired()
|
|
}
|
|
|
|
// handleEvent applies an EventStreamRevoked event to the local cache. Other
|
|
// event types on the channel are ignored.
|
|
func (s *Store) handleEvent(evt cache.Event) {
|
|
if evt.Type != EventStreamRevoked {
|
|
return
|
|
}
|
|
var r Revocation
|
|
if err := json.Unmarshal([]byte(evt.Payload), &r); err != nil {
|
|
slog.Debug("streamrevoke event unmarshal failed", "error", err)
|
|
return
|
|
}
|
|
if r.expired(time.Now()) {
|
|
return
|
|
}
|
|
s.applyLocal(r)
|
|
}
|
|
|
|
// reconcileFromRedis SCANs the revocation namespace and applies every live
|
|
// entry to the local cache. Redis is authoritative for entries it holds; TTL
|
|
// expiry there is mirrored by prune-on-read locally.
|
|
func (s *Store) reconcileFromRedis(ctx context.Context) {
|
|
var cursor uint64
|
|
now := time.Now()
|
|
for {
|
|
keys, next, err := s.rdb.Scan(ctx, cursor, scanPattern, 256).Result()
|
|
if err != nil {
|
|
slog.Warn("streamrevoke redis scan failed", "error", err)
|
|
return
|
|
}
|
|
for _, k := range keys {
|
|
val, err := s.rdb.Get(ctx, k).Result()
|
|
if err != nil {
|
|
// Key may have expired between SCAN and GET; skip it.
|
|
continue
|
|
}
|
|
var r Revocation
|
|
if err := json.Unmarshal([]byte(val), &r); err != nil {
|
|
slog.Debug("streamrevoke redis unmarshal failed", "error", err, "key", k)
|
|
continue
|
|
}
|
|
if !r.expired(now) {
|
|
s.applyLocal(r)
|
|
}
|
|
}
|
|
cursor = next
|
|
if cursor == 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|