Files
anchorage/internal/pkg/scheduler/consumer.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

227 lines
6.8 KiB
Go

// Package scheduler owns a node's job consumer, rebalancer, requeue
// sweeper, and reconciler loops.
//
// Job consumer: one pull subscription per node on pin.jobs.<myNodeID>.
// Dispatches to the local Kubo backend, updates the placement row with
// WHERE fence = $n, bumps or decrements pin_refcount, publishes an event.
package scheduler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"anchorage/internal/pkg/ids"
"anchorage/internal/pkg/ipfs"
"anchorage/internal/pkg/metrics"
"anchorage/internal/pkg/pin"
"anchorage/internal/pkg/store"
)
// PinJobsStream is the JetStream name for the pin work queue.
const PinJobsStream = "PIN_JOBS"
// EnsureStreams creates (or updates) the PIN_JOBS and PIN_EVENTS streams.
// Idempotent; safe to call on every boot.
//
// The replicas argument is the operator's requested replication factor;
// if the running NATS deployment is smaller (e.g., single-node embedded
// NATS with replicas=3 in config) we retry with replicas=1. This matches
// the plan's "Capped at clusterSize at runtime" note without poking
// brittle internals.
func EnsureStreams(ctx context.Context, js jetstream.JetStream, replicas int) error {
if replicas <= 0 {
replicas = 1
}
mk := func(cfg jetstream.StreamConfig) error {
_, err := js.CreateOrUpdateStream(ctx, cfg)
if err == nil {
return nil
}
// JetStream reports "insufficient resources" / "replicas > cluster size"
// variants; rather than pattern-matching their error strings, retry
// once with replicas=1 which every deployment accepts.
if replicas > 1 {
cfg.Replicas = 1
if _, err2 := js.CreateOrUpdateStream(ctx, cfg); err2 == nil {
return nil
}
}
return err
}
if err := mk(jetstream.StreamConfig{
Name: PinJobsStream,
Subjects: []string{pin.JobsSubjectPrefix + ".*"},
Retention: jetstream.WorkQueuePolicy,
Duplicates: 5 * time.Minute,
Replicas: replicas,
}); err != nil {
return fmt.Errorf("ensure PIN_JOBS: %w", err)
}
if err := mk(jetstream.StreamConfig{
Name: "PIN_EVENTS",
Subjects: []string{pin.EventsSubjectPrefix + ".>"},
Retention: jetstream.LimitsPolicy,
MaxAge: 1 * time.Hour,
Replicas: replicas,
}); err != nil {
return fmt.Errorf("ensure PIN_EVENTS: %w", err)
}
return nil
}
// Consumer is a single node's job worker.
type Consumer struct {
NodeID ids.NodeID
Store store.Store
Backend ipfs.Backend
NC *nats.Conn
JS jetstream.JetStream
}
// Run pulls pin.jobs.<NodeID> messages until ctx is cancelled. Honors
// nodes.status: on transitions to drained, the pull loop stops; the
// caller is responsible for restarting it when the node is uncordoned.
func (c *Consumer) Run(ctx context.Context) error {
consName := "worker-" + c.NodeID.String()
cons, err := c.JS.CreateOrUpdateConsumer(ctx, PinJobsStream, jetstream.ConsumerConfig{
Name: consName,
Durable: consName,
FilterSubject: fmt.Sprintf("%s.%s", pin.JobsSubjectPrefix, c.NodeID.String()),
AckPolicy: jetstream.AckExplicitPolicy,
AckWait: 30 * time.Second,
MaxDeliver: 5,
})
if err != nil {
return fmt.Errorf("create consumer: %w", err)
}
for {
if ctx.Err() != nil {
return ctx.Err()
}
// Check for drain: if nodes.status='drained', pause the loop.
// One-shot check per outer iteration is cheap.
if n, err := c.Store.Nodes().Get(ctx, c.NodeID); err == nil && n.Status == store.NodeStatusDrained {
slog.Info("scheduler: consumer paused (drained)", "node_id", c.NodeID)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
msgs, err := cons.Fetch(10, jetstream.FetchMaxWait(2*time.Second))
if err != nil {
if errors.Is(err, nats.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) {
metrics.SchedulerFetch.WithLabelValues(c.NodeID.String(), "timeout").Inc()
continue
}
metrics.SchedulerFetch.WithLabelValues(c.NodeID.String(), "err").Inc()
slog.Warn("scheduler: fetch", "err", err)
time.Sleep(500 * time.Millisecond)
continue
}
for m := range msgs.Messages() {
metrics.SchedulerFetch.WithLabelValues(c.NodeID.String(), "ok").Inc()
c.handle(ctx, m)
}
}
}
func (c *Consumer) handle(ctx context.Context, m jetstream.Msg) {
var job pin.Job
if err := json.Unmarshal(m.Data(), &job); err != nil {
slog.Warn("scheduler: bad job payload", "err", err)
_ = m.Term()
return
}
if err := c.execute(ctx, job); err != nil {
slog.Warn("scheduler: execute", "err", err, "request_id", job.RequestID)
_ = m.Nak()
return
}
_ = m.Ack()
}
// execute dispatches the job to Kubo and updates state.
//
// The fence check in UpdatePlacementFenced is what keeps zombie writes
// from clobbering a reassigned placement: if the fence has been bumped
// by the rebalancer, the UPDATE affects 0 rows and we silently drop.
func (c *Consumer) execute(ctx context.Context, j pin.Job) error {
orgID, err := ids.ParseOrg(j.OrgID)
if err != nil {
return err
}
rid, err := ids.ParsePin(j.RequestID)
if err != nil {
return err
}
switch j.Action {
case "pin":
if err := c.Backend.Pin(ctx, j.CID, j.Origins); err != nil {
return c.markPlacement(ctx, rid, j, store.PinStatusFailed, err.Error())
}
return c.markPlacement(ctx, rid, j, store.PinStatusPinned, "")
case "unpin":
// Decrement refcount; unpin Kubo only on 0.
remaining, err := c.Store.Pins().DecRefcount(ctx, c.NodeID, j.CID)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return err
}
if remaining <= 0 {
if err := c.Backend.Unpin(ctx, j.CID); err != nil && !errors.Is(err, ipfs.ErrNotFound) {
return err
}
_ = c.Store.Pins().DeleteRefcountIfZero(ctx, c.NodeID, j.CID)
}
pin.PublishEvent(c.NC, pin.Event{
RequestID: j.RequestID,
OrgID: orgID.String(),
Status: "unpinned",
NodeID: c.NodeID.String(),
})
metrics.SchedulerAcks.WithLabelValues(c.NodeID.String(), "unpinned").Inc()
return nil
default:
return fmt.Errorf("scheduler: unknown action %q", j.Action)
}
}
func (c *Consumer) markPlacement(ctx context.Context, rid ids.PinID, j pin.Job, status, reason string) error {
var fr *string
if reason != "" {
fr = &reason
}
n, err := c.Store.Pins().UpdatePlacementFenced(ctx, rid, c.NodeID, status, fr, j.Fence)
if err != nil {
return err
}
if n == 0 {
// Fence mismatch — reassigned already. Silent no-op.
slog.Info("scheduler: fence mismatch, dropping",
"request_id", j.RequestID, "node_id", c.NodeID, "fence", j.Fence)
metrics.SchedulerAcks.WithLabelValues(c.NodeID.String(), "fence-mismatch").Inc()
return nil
}
pin.PublishEvent(c.NC, pin.Event{
RequestID: j.RequestID,
OrgID: j.OrgID,
Status: status,
NodeID: c.NodeID.String(),
Reason: reason,
})
metrics.SchedulerAcks.WithLabelValues(c.NodeID.String(), status).Inc()
return nil
}