Files
anchorage/internal/pkg/pin/livenodes.go
T
57_Wolve 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

113 lines
3.1 KiB
Go

package pin
import (
"context"
"sync"
"sync/atomic"
"time"
"anchorage/internal/pkg/cache"
"anchorage/internal/pkg/store"
)
// LiveNodesSource abstracts how the pin service discovers the current
// live node set. Production injects a caching wrapper; tests can pass
// a fake that returns a static slice.
type LiveNodesSource interface {
Live(ctx context.Context) ([]*store.Node, error)
}
// storeLiveNodes is the trivial pass-through (no cache).
type storeLiveNodes struct{ s store.Store }
func (s storeLiveNodes) Live(ctx context.Context) ([]*store.Node, error) {
return s.s.Nodes().ListLive(ctx)
}
// CachedLiveNodes is a bounded, TTL'd cache over store.NodeStore.ListLive.
//
// This is the "placement/live-nodes" cache from the plan's caching
// table. The value is a single slice, so MaxCost is trivial; the
// motivation is latency: POST /v1/pins must not block on a Postgres
// round-trip per request to compute rendezvous placements.
//
// Freshness is controlled by two signals:
//
// 1. A short TTL (default 5s) bounds staleness unconditionally.
// 2. Invalidate() drops the cached value immediately; the node
// package's heartbeat consumer calls it on every heartbeat so the
// in-memory view converges within a heartbeat interval.
type CachedLiveNodes struct {
s store.Store
ttl time.Duration
mu sync.Mutex
cache []*store.Node
cachedAt time.Time
// Counters for /v1/admin/cache-stats. Atomics so Stats() can read
// without holding mu.
hits atomic.Uint64
misses atomic.Uint64
}
// NewCachedLiveNodes constructs a cache with the given TTL. ttl <= 0
// disables caching and every call passes through.
//
// The cache auto-registers with cache.Register so its counters show up
// in /v1/admin/cache-stats alongside any ristretto-backed caches.
func NewCachedLiveNodes(s store.Store, ttl time.Duration) *CachedLiveNodes {
c := &CachedLiveNodes{s: s, ttl: ttl}
cache.Register(c)
return c
}
// Stats implements cache.StatsProvider. Only Hits/Misses are populated
// (the live-nodes cache is a single slice, so the keys/cost counters
// don't really apply).
func (c *CachedLiveNodes) Stats() cache.Stats {
return cache.Stats{
Name: "placement/live-nodes",
Hits: c.hits.Load(),
Misses: c.misses.Load(),
}
}
// Live returns the live node list, hitting the store only when the
// cached copy is missing or expired.
func (c *CachedLiveNodes) Live(ctx context.Context) ([]*store.Node, error) {
if c.ttl <= 0 {
c.misses.Add(1)
return c.s.Nodes().ListLive(ctx)
}
c.mu.Lock()
if c.cache != nil && time.Since(c.cachedAt) < c.ttl {
out := append([]*store.Node(nil), c.cache...)
c.mu.Unlock()
c.hits.Add(1)
return out, nil
}
c.mu.Unlock()
c.misses.Add(1)
fresh, err := c.s.Nodes().ListLive(ctx)
if err != nil {
return nil, err
}
c.mu.Lock()
c.cache = append([]*store.Node(nil), fresh...)
c.cachedAt = time.Now()
c.mu.Unlock()
return fresh, nil
}
// Invalidate drops the cached snapshot so the next Live() re-fetches.
// Intended to be called from the node heartbeat consumer whenever a
// peer's presence changes.
func (c *CachedLiveNodes) Invalidate() {
c.mu.Lock()
c.cache = nil
c.cachedAt = time.Time{}
c.mu.Unlock()
}