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.
113 lines
3.1 KiB
Go
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()
|
|
}
|