parti

package module
v2.10.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: Apache-2.0 Imports: 32 Imported by: 0

README

Parti

Go Reference Go Report Card

Parti is a Go library for building partitioned workloads on NATS. It provides a complete toolkit for sharding work across workers — dynamic partitioning with leader-coordinated rebalancing, static partitioning for fixed-topology deployments (e.g. Kubernetes StatefulSets), and resilient JetStream consumers with auto-recovery from durable deletion.

| Its headline capability is solving the coordination gap NATS leaves open when workers and partitions change at runtime.

The Problem

You want to shard work across a fleet of workers — each worker owns a subset of partitions (shards, tenants, key ranges). At runtime, two things change independently:

  • The worker fleet — pods scale up and down, deploys roll, instances crash and restart.
  • The partition set — tenants are added or removed, shards split or merge, keyspaces grow.

NATS JetStream gives you durable consumers, but no built-in way to rebalance partition ownership across a changing fleet. Without a coordination layer, you end up with two workers consuming the same partition during a reassignment, partitions left unprocessed during a deploy, or assignments that churn every time a pod restarts.

What Parti Provides

Parti is the coordination layer NATS doesn't ship with — it handles the seamless re-binding of partitions to workers when either side changes:

  • Stable worker IDs across restarts so rolling updates don't churn assignments.
  • Leader-elected assignment so every worker agrees on who owns what.
  • Two-phase handoff (Prepare/Commit) so no partition is processed by two workers during reassignment.
  • Dynamic partition discovery so you can add or remove partitions without restarting workers.
  • Cache-affinity rebalancing so >80% partition locality is preserved when the fleet size changes.
  • Degraded-mode operation so workers keep processing with cached assignments when NATS connectivity is lost.

Use it for stream processors, job queues, sharded caches, or any system where a dynamic set of workers must own a dynamic set of partitions.

Key Features

Dynamic Partitioning (parti.Manager)
  • Stable Worker IDs: Workers claim stable IDs (e.g., worker-0, worker-1) that persist across restarts, minimizing assignment churn during rolling updates.
  • Leader-Based Assignment: A single leader worker calculates assignments, ensuring consistency and preventing split-brain scenarios.
  • Dynamic Partition Discovery: Supports dynamic partition updates via NATS KV without restarting workers.
  • Two-Phase Handoff: Orders partition release during reassignment via a worker-driven prepare/commit claim protocol so a partition is never left unowned; combined with the processing gate it minimizes overlapping processing (see docs/LIFECYCLE.md for the per-tier overlap contract).
  • Degraded Mode: Continues operation using cached assignments when NATS connectivity is lost, prioritizing availability over strict consistency during outages.
  • Processing Gate: Per-message admission control based on assignment status, NAKing deliveries for revoked partitions.
  • Cache Affinity: Preserves >80% partition locality during rebalancing using consistent hashing.
  • Weighted Assignment: Supports partition weights for uneven workload distribution.
  • Label-Based Routing: Pins labeled partitions to matching worker pools (e.g., a dedicated "VIP" tier) with runtime promotion/demotion, an empty-pool park-then-spill grace window, and a stale-incarnation guard for stable-ID takeover. See Label-Based Partition Assignment.
Consumer Auto-Recovery
  • Automatic Durable Recreation: When a JetStream durable consumer is unexpectedly deleted (server restart, InactiveThreshold expiry, administrative action), the consumer detects the deletion and recreates itself automatically — no worker restart required.
  • Four Recovery Strategies: Choose how the recreated consumer resumes — skip missed messages (RecoverFromNew), replay from the last auto-acknowledged message (RecoverFromLastProcessed), or replay from the beginning (RecoverFromBeginning).
  • Eager Validation: Incompatible combinations (e.g., RecoverFromLastProcessed on a Queue consumer) are rejected at construction time with ErrInvalidConfig, not at runtime.
  • Opt-In, Zero-Cost Default: Recovery is disabled (RecoveryDisabled) by default — enabling it requires a single option.

See Consumer Helpers for the full strategy matrix and per-consumer support table.

Static Partitioning (partition package)
  • Deterministic Routing: Messages are routed to fixed partitions using xxh3 hashing on partition keys.
  • StatefulSet Integration: Designed for Kubernetes StatefulSet deployments where each pod handles a fixed partition based on its ordinal.
  • Dual Protocol Support: Works with both core NATS (Publisher/Subscriber) and JetStream (JSPublisher). Consuming from JetStream partitions is handled by the consumer package (Static, Dynamic).
  • Subject Pattern Templates: Flexible subject patterns with {{partition}} and {{key}} placeholders.
  • Zero Coordination: No leader election or external coordination required—simple and predictable.

See the partition package README for detailed documentation.

Installation

go get github.com/arloliu/parti/v2

Quick Start

Here's a complete example of setting up a worker with Parti.

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/arloliu/parti/v2"
    "github.com/arloliu/parti/v2/consumer"
    "github.com/arloliu/parti/v2/source"
    "github.com/arloliu/parti/v2/strategy"
    "github.com/nats-io/nats.go"
    "github.com/nats-io/nats.go/jetstream"
)

func main() {
    // 1. Connect to NATS
    nc, err := nats.Connect(nats.DefaultURL)
    if err != nil {
        log.Fatal(err)
    }
    defer nc.Close()

    js, err := jetstream.New(nc)
    if err != nil {
        log.Fatal(err)
    }

    // 2. Define partitions (e.g., 10 partitions)
    var partitions []parti.Partition
    for i := 0; i < 10; i++ {
        partitions = append(partitions, parti.Partition{
            Keys: []string{"orders", fmt.Sprintf("%d", i)},
        })
    }

    // 3. Configure Manager
    cfg := &parti.Config{
        WorkerIDPrefix: "worker",
        WorkerIDMax:    10,
    }
    if err := parti.SetDefaults(cfg); err != nil {
        log.Fatal(err)
    }

    // 4. Create Manager components
    src := source.NewStatic(partitions)
    hashStrategy := strategy.NewConsistentHash()

    // 5. Create a Dynamic consumer (manages per-partition JetStream consumers)
    // Parti Manager calls consumer.Update() when assignments change.
    handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
        log.Printf("Processing message on subject: %s", msg.Subject())
        return nil // auto-ack on nil return
    })

    dyn, err := consumer.NewDynamic(js, "ORDERS", "processor",
        "orders.{{.PartitionID}}.complete", handler,
        consumer.WithProcessingGate(&consumer.ProcessingGateConfig{Enabled: true}),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer dyn.Stop(context.Background())

    // 6. Create and Start Manager
    mgr, err := parti.NewManager(cfg, js, src, hashStrategy,
        parti.WithWorkerConsumerUpdater(dyn), // Manager calls dyn.Update on assignment changes
    )
    if err != nil {
        log.Fatal(err)
    }

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // Start returns once the synchronous sanity-check phase
    // (worker-ID claim, KV buckets, election, heartbeat, calculator)
    // succeeds. The initial assignment fetch + apply runs in the
    // background. Use WaitState to block until the manager is ready
    // to process work.
    if err := mgr.Start(ctx); err != nil {
        log.Fatal(err)
    }
    if err := <-mgr.WaitState(parti.StateStable, 30*time.Second); err != nil {
        log.Fatal(err)
    }

    log.Printf("Worker started with ID: %s", mgr.WorkerID())

    // Wait for shutdown signal
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
    <-sig

    log.Println("Shutting down...")
    mgr.Stop(ctx)
}

Documentation

License

Apache 2.0 License

Documentation

Overview

Package parti is a Go library for building partitioned workloads on NATS. It provides a complete toolkit for sharding work across workers: dynamic partitioning with leader-coordinated rebalancing, static partitioning for fixed-topology deployments (e.g. Kubernetes StatefulSets), and resilient JetStream consumers with auto-recovery from durable deletion.

Its headline capability is solving the coordination gap NATS leaves open when both the worker fleet and the partition set change at runtime — providing stable worker identities, cache-affinity-aware rebalancing, and adaptive stabilization without external coordination services. Static partitioning and consumer auto-recovery live in the partition and consumer subpackages respectively.

Quick Start

Basic usage with default settings:

import (
    "github.com/arloliu/parti/v2"
    "github.com/arloliu/parti/v2/source"
    "github.com/arloliu/parti/v2/strategy"
)

cfg := parti.Config{
    WorkerIDPrefix: "worker",
    WorkerIDMin:    0,
    WorkerIDMax:    999,
}

partitions := []parti.Partition{{Keys: []string{"0"}}, {Keys: []string{"1"}}, {Keys: []string{"2"}}}
src := source.NewStatic(partitions)
// Connect with MaxReconnects(-1) so the client rides through transient
// NATS outages instead of going CLOSED. See docs/OPERATIONS.md
// "NATS Client Connection" for the full posture.
natsConn, _ := nats.Connect(natsURL, nats.MaxReconnects(-1), nats.RetryOnFailedConnect(true))
js, _ := jetstream.New(natsConn)
assignmentStrategy := strategy.NewConsistentHash()
mgr, _ := parti.NewManager(&cfg, js, src, assignmentStrategy)

if err := mgr.Start(ctx); err != nil {
    log.Fatal(err)
}
// Start returns once the synchronous sanity-check phase succeeds.
// Wait for the background runner to apply the initial assignment.
if err := <-mgr.WaitState(parti.StateStable, 30*time.Second); err != nil {
    log.Fatal(err)
}
defer mgr.Stop(context.Background())

Key Features

  • Stable Worker IDs: Workers claim stable IDs for consistent assignment during rolling updates
  • Leader-Based Assignment: One worker calculates assignments without external coordination
  • Adaptive Rebalancing: Different stabilization windows for cold start (30s) vs planned scale (10s)
  • Cache Affinity: Preserves >80% partition locality during rebalancing
  • Weighted Assignment: Supports partition weights for load balancing
  • Static Partitioning: Zero-coordination routing for StatefulSet-style deployments (partition subpackage)
  • Consumer Auto-Recovery: JetStream consumers detect and recreate deleted durables automatically (consumer subpackage)

Architecture

Workers progress through a state machine:

INIT → CLAIMING_ID → ELECTION → WAITING_ASSIGNMENT → STABLE

The leader monitors heartbeats, detects topology changes, and publishes new assignments. All workers watch for assignment updates and trigger callbacks when their partitions change.

Advanced Usage

Custom strategy with options:

import (
    "github.com/arloliu/parti/v2"
    "github.com/arloliu/parti/v2/source"
    "github.com/arloliu/parti/v2/strategy"
)

assignmentStrategy := strategy.NewConsistentHash(
    strategy.WithVirtualNodes(300),
)

hooks := &parti.Hooks{
    OnAssignmentChanged: func(ctx context.Context, oldPartitions, newPartitions []parti.Partition) error {
        // Handle full assignment change; derive added/removed by diffing old vs new if needed.
        return nil
    },
}

partitions := []parti.Partition{{Keys: []string{"0"}}, {Keys: []string{"1"}}, {Keys: []string{"2"}}}
src := source.NewStatic(partitions)
js, _ := jetstream.New(natsConn)
mgr, _ := parti.NewManager(&cfg, js, src, assignmentStrategy,
    parti.WithHooks(hooks),
)

See the examples/ directory for complete working examples.

Index

Examples

Constants

View Source
const (
	// DegradeReasonKVUnavailable is the connected-but-KV-unavailable condition: a
	// bucket reachable on the live connection but unable to serve ops because its
	// RAFT quorum is lost. Kept distinct from DegradeReasonKVErrorThreshold so the
	// operator surface distinguishes a quorum-loss op stall from a whole-bucket
	// wipe, and so the contract that whole-bucket loss is the ONLY path to the
	// threshold reason is preserved. Recovery-scoped: exit requires a heartbeat Put
	// stamped after the degrade.
	DegradeReasonKVUnavailable = "kv-unavailable"

	// DegradeReasonEnumerationStall is a sustained leader-side worker-enumeration
	// (heartbeat Keys scan) stall that the connectivity / degrading classifiers
	// miss. Kept distinct so the recovery exit can require an enumeration success
	// before exiting. Recovery-scoped (leader-gated).
	DegradeReasonEnumerationStall = "heartbeat-enumeration-stall"

	// DegradeReasonAssignmentWatcherExhausted is emitted by the assignment-watcher
	// retry envelope's permanent-failure callback when the watcher exhausts its
	// attempt budget. Distinct from DegradeReasonKVErrorThreshold so operators can
	// tell "assignment bucket is unrecoverable" from "accumulated transient errors".
	DegradeReasonAssignmentWatcherExhausted = "assignment-watcher-exhausted"

	// DegradeReasonKVErrorThreshold is the whole-bucket-loss reason from
	// recordKVError once the KV-error window crosses the threshold. Whole-bucket
	// loss (connectivity / degrading-JetStream) is the ONLY path to this reason —
	// transient kv-unavailable entries are clearable by a healthy op and degrade
	// with DegradeReasonKVUnavailable instead (the AGENTS.md contract).
	DegradeReasonKVErrorThreshold = "KV error threshold exceeded"

	// DegradeReasonNATSConnectionDown is set by the connection monitor once the
	// NATS connection has been down past the enter threshold.
	DegradeReasonNATSConnectionDown = "NATS connection down"

	// DegradeReasonStreamMissingRecoveryExhausted is the dynamic-consumer
	// stream-missing recovery path: routed through the stream-missing observer
	// rather than the generic KV-error threshold.
	DegradeReasonStreamMissingRecoveryExhausted = "stream-missing-recovery-exhausted"

	// DegradeReasonStartupTimeout is fired by the startup watchdog when the manager
	// is still not Stable after StartupTimeout.
	DegradeReasonStartupTimeout = "startup-timeout"

	// DegradeReasonStartupBackgroundPanic is fired when a background startup
	// goroutine panics.
	DegradeReasonStartupBackgroundPanic = "startup-background-panic"
)

Degrade reasons — the single registry of every enterDegraded reason string.

These values are an operator-facing contract: they are passed verbatim to the OnDegraded hook (see [Hooks.OnDegraded]) and are documented in docs/API_REFERENCE.md. The string VALUES are frozen — changing one is an operator-visible break — so they are pinned literally by TestDegradeReason_LiteralValues in addition to being referenced by name here.

Centralizing them in one block (rather than scattered consts + inline literals across manager_degraded.go, manager_assignment.go, manager_setup.go, and manager_startup_async.go) keeps the taxonomy in one place and lets the recovery-exit gates branch on names instead of magic strings.

View Source
const (
	StateInit              = types.StateInit
	StateClaimingID        = types.StateClaimingID
	StateElection          = types.StateElection
	StateWaitingAssignment = types.StateWaitingAssignment
	StateStable            = types.StateStable
	StateScaling           = types.StateScaling
	StateRebalancing       = types.StateRebalancing
	StateEmergency         = types.StateEmergency
	StateDegraded          = types.StateDegraded
	StateShutdown          = types.StateShutdown
)

Re-export State constants from the internal types package.

View Source
const (
	HandoffStateUnknown = types.HandoffStateUnknown
	HandoffStateStable  = types.HandoffStateStable
	HandoffStatePrepare = types.HandoffStatePrepare
	HandoffStateCommit  = types.HandoffStateCommit
)

Re-export HandoffState constants.

Variables

View Source
var (
	ErrInvalidConfig              = types.ErrInvalidConfig
	ErrNATSConnectionRequired     = types.ErrNATSConnectionRequired
	ErrPartitionSourceRequired    = types.ErrPartitionSourceRequired
	ErrAssignmentStrategyRequired = types.ErrAssignmentStrategyRequired
	ErrAlreadyStarted             = types.ErrAlreadyStarted
	ErrNotStarted                 = types.ErrNotStarted
	ErrNotImplemented             = types.ErrNotImplemented
	ErrNoWorkersAvailable         = types.ErrNoWorkersAvailable
	ErrInvalidWorkerID            = types.ErrInvalidWorkerID
	ErrElectionFailed             = types.ErrElectionFailed
	ErrConnectivity               = types.ErrConnectivity
	ErrDegraded                   = types.ErrDegraded
	ErrIDClaimFailed              = types.ErrIDClaimFailed
	ErrAssignmentFailed           = types.ErrAssignmentFailed
	ErrInvalidPreset              = types.ErrInvalidPreset
	// ErrStreamMissing is the operator-actionable umbrella error surfaced
	// when the dynamic partition consumer's recovery flow determines that
	// the underlying JetStream stream is absent (or post-hook recovery
	// fails for any reason — restored consumer config mismatch, etc.).
	// Callers branch via errors.Is(err, parti.ErrStreamMissing) inside
	// their Hooks.OnError handler. See [types.ErrStreamMissing] for the
	// full contract.
	ErrStreamMissing = types.ErrStreamMissing
)

Re-export sentinel errors from the internal types package.

These are stable, comparable values intended for use with errors.Is(). Keeping them in the root package provides a convenient public API while allowing internal packages to depend on the `types` subpackage without importing the root package.

View Source
var ErrKVUnavailable = errors.New("kv unavailable")

ErrKVUnavailable marks a KV operation that failed because its backing bucket is reachable on the live NATS connection but cannot serve the op (deadline / no-responders) — the quorum-loss condition the connection-status monitor and the connectivity/degrading-JetStream classifiers all miss.

It is applied ONLY at the manager's own periodic KV-op call sites via [markKVUnavailable], never added to any global predicate. That keeps an unwrapped deadline / no-responders from anywhere else (notably the peer-takeover claim path through onClaimerError) out of the degraded circuit, preserving the cross-feature classification contracts.

Functions

func BuildControlPlaneKVConfig added in v2.4.1

func BuildControlPlaneKVConfig(bucket string, ttl time.Duration, storage jetstream.StorageType) jetstream.KeyValueConfig

BuildControlPlaneKVConfig returns the canonical jetstream.KeyValueConfig for a Parti control-plane KV bucket. It is a thin wrapper around internal/kvbuckets.BuildKeyValueConfig, re-exported here so that external packages (such as provision/) can construct byte-equivalent configurations without importing an internal package.

See internal/kvbuckets.BuildKeyValueConfig for the full contract, including the byte-equivalence guarantee with (*Manager).ensureKVBucket.

func SetDefaults

func SetDefaults(cfg *Config) error

SetDefaults applies default values to zero-valued configuration fields. If a field is zero-valued, it will be set to the corresponding default value.

Returns:

  • error: Non-nil if the default tags on the Config struct are malformed
Example

This example demonstrates using SetDefaults to fill in default values for a partially configured Config, such as one loaded from YAML.

package main

import (
	"fmt"

	"github.com/arloliu/parti/v2"
)

func main() {
	// Simulate a Config loaded from YAML with only some fields set
	cfg := parti.Config{
		WorkerIDPrefix: "custom-worker",
		WorkerIDMax:    50,
	}

	// Fill remaining fields with defaults
	if err := parti.SetDefaults(&cfg); err != nil {
		fmt.Printf("Failed to set defaults: %v\n", err)
		return
	}

	fmt.Printf("Prefix: %s, HeartbeatInterval: %v\n", cfg.WorkerIDPrefix, cfg.HeartbeatInterval)
}
Output:
Prefix: custom-worker, HeartbeatInterval: 5s

Types

type AlertLevel

type AlertLevel int

AlertLevel represents the severity level of degraded mode alerts.

const (
	// AlertLevelInfo indicates informational alerts (least severe).
	AlertLevelInfo AlertLevel = iota
	// AlertLevelWarn indicates warning alerts.
	AlertLevelWarn
	// AlertLevelError indicates error alerts.
	AlertLevelError
	// AlertLevelCritical indicates critical alerts (most severe).
	AlertLevelCritical
)

func (AlertLevel) String

func (l AlertLevel) String() string

String returns the string representation of the alert level.

Returns:

  • string: Alert level name ("Info", "Warn", "Error", "Critical", or "Unknown")

type Assignment

type Assignment = types.Assignment

Re-export types from the internal types package.

This file provides a stable, backward-compatible public API for the library's core types and interfaces. It uses type aliases to re-export definitions from the `types` subpackage, which contains the actual implementations.

This pattern solves the "import cycle" problem by allowing internal packages to depend on `types` without depending on the root `parti` package, while still providing a convenient `parti.State`, `parti.Logger`, etc. for users.

type AssignmentDivergenceMetricsRecorder added in v2.10.2

type AssignmentDivergenceMetricsRecorder = types.AssignmentDivergenceMetricsRecorder

AssignmentDivergenceMetricsRecorder is the optional equal-version divergence observability capability a MetricsCollector may additionally implement; see types.AssignmentDivergenceMetricsRecorder for the label contract.

type AssignmentMetrics

type AssignmentMetrics = types.AssignmentMetrics

Re-export interfaces from the internal types package for convenience.

type AssignmentStrategy

type AssignmentStrategy = types.AssignmentStrategy

Re-export interfaces from the internal types package for convenience.

type AuthorityChoice added in v2.4.0

type AuthorityChoice int

AuthorityChoice is the result of the dual-read source-of-truth selection rule (§3.6). It tells the manager which channel — the commit watcher or the legacy alias watcher — should drive the next applyAssignment call for the observation in hand.

const (
	// AuthorityNone indicates no usable authority is available; the manager
	// should wait for a fresher observation.
	AuthorityNone AuthorityChoice = iota
	// AuthorityCommit indicates the commit watcher's payload should drive
	// the apply.
	AuthorityCommit
	// AuthorityLegacyAlias indicates the legacy alias watcher's payload
	// should drive the apply (rolling-upgrade or new-leader→old-leader
	// handoff window).
	AuthorityLegacyAlias
)

type CalculatorMetrics

type CalculatorMetrics = types.CalculatorMetrics

Re-export interfaces from the internal types package for convenience.

type CapabilityReporter added in v2.4.0

type CapabilityReporter interface {
	// Capabilities returns the OR of capability bits this reporter has
	// successfully wired at runtime. Must be safe for concurrent use,
	// non-blocking, and monotonic for runtime-wireup bits.
	Capabilities() uint32
}

CapabilityReporter is an optional interface a WorkerConsumerUpdater MAY implement to report runtime capabilities back to the Manager.

When the registered updater (or any child of a composite updater) satisfies this interface, Manager queries Capabilities() after each handoff apply attempt and ORs the returned bits into its capability bitmask via SetCapability.

Implementations MUST be:

  • Safe for concurrent calls. Capabilities() may be invoked from the manager-apply goroutine (which calls reportConsumerCapabilities after every handoffCoordinator.Apply attempt) and may race with the updater's own UpdateWorkerConsumer calls. The heartbeat publisher does NOT call Capabilities() directly — it reads Manager.Capabilities() (the live bitmask callback) — so the race surface is reporter ↔ updater, not reporter ↔ heartbeat.
  • Non-blocking. Capabilities() is invoked on every apply attempt; must not perform I/O or acquire locks held by long operations. A simple atomic load is the expected shape.
  • Monotonic for runtime-wire-up bits such as CapProcessingGate: once a capability has been successfully wired (e.g., a handler wrapped with the processing gate), the corresponding bit MUST remain set for the rest of the updater's lifetime even if a later per-subject create fails. The bit reflects "this updater has at least one wired component", not "all components are currently wired".

Manager integration semantics for the reporter integration are OR-only for runtime-wire-up bits: reportConsumerCapabilities calls SetCapability(bit, true) for known reported bits and never clears. (Manager.SetCapability itself supports clearing bits via active=false, used by other components — but the reporter pathway only sets.) Implementers should not rely on a returned-zero Capabilities() causing the manager to clear a previously-reported bit; it won't.

Returning 0 is always safe (no caps advertised).

type CompositeConsumerUpdater

type CompositeConsumerUpdater struct {
	// contains filtered or unexported fields
}

CompositeConsumerUpdater combines multiple WorkerConsumerUpdater instances. When UpdateWorkerConsumer is called, it fans out to all registered updaters.

Use cases:

  • Register both WorkerConsumer (per-partition) and BroadcastConsumer (fan-out)
  • Multiple BroadcastConsumer instances for different stream/subject patterns

Error handling:

  • Calls all updaters even if some fail
  • Returns a combined error with all failures

func NewCompositeConsumerUpdater

func NewCompositeConsumerUpdater(updaters ...WorkerConsumerUpdater) *CompositeConsumerUpdater

NewCompositeConsumerUpdater creates a composite from multiple updaters. All provided updaters will receive partition updates when UpdateWorkerConsumer is called.

Example:

wc, _ := durable.NewWorkerConsumer(js, wcConfig, handler1)
bc, _ := durable.NewBroadcastConsumer(js, bcConfig, handler2)
composite := parti.NewCompositeConsumerUpdater(wc, bc)
mgr, _ := parti.NewManager(cfg, js, src, strategy,
    parti.WithWorkerConsumerUpdater(composite),
)
Example

This example demonstrates composing multiple WorkerConsumerUpdater instances using CompositeConsumerUpdater.

package main

import (
	"fmt"

	"github.com/arloliu/parti/v2"
)

func main() {
	// In production, these would be real consumer instances (e.g., Dynamic, Broadcast).
	// NewCompositeConsumerUpdater filters nil updaters, so only non-nil ones are kept.
	composite := parti.NewCompositeConsumerUpdater( /* updater1, updater2 */ )

	// Add updaters later via Add
	fmt.Printf("Composite updater created, initial count: %d\n", composite.Len())
}
Output:
Composite updater created, initial count: 0

func (*CompositeConsumerUpdater) Add

func (c *CompositeConsumerUpdater) Add(updaters ...WorkerConsumerUpdater)

Add appends additional updaters to the composite. This is useful for dynamically registering consumers after creation.

Newly-added children inherit any state the composite has already received: an installed stream-missing observer (via SetOnStreamMissingError) and the last observed fleet worker-count (via ObserveWorkerCount), so a late registration does not silently miss either. Inherited-state forwarding runs outside c.mu so a slow child cannot serialize peers or deadlock a re-entrant Add.

func (*CompositeConsumerUpdater) Capabilities added in v2.4.0

func (c *CompositeConsumerUpdater) Capabilities() uint32

Capabilities implements CapabilityReporter by ORing the runtime capability bits reported by each child updater that implements CapabilityReporter. Children that do not implement the interface contribute zero bits.

Composition rule: OR. A capability is considered wired for the composite if any child updater has wired it.

Returns:

  • uint32: OR of all child-reported capability bits, or 0 if no child implements CapabilityReporter or none has wired any bits.

func (*CompositeConsumerUpdater) Len

func (c *CompositeConsumerUpdater) Len() int

Len returns the number of registered updaters.

func (*CompositeConsumerUpdater) ObserveWorkerCount added in v2.8.0

func (c *CompositeConsumerUpdater) ObserveWorkerCount(n int)

ObserveWorkerCount implements FleetSizeObserver by caching the worker-count and forwarding it to every child that implements FleetSizeObserver. The cache is replayed to children added later via Add.

func (*CompositeConsumerUpdater) SetOnStreamMissingError added in v2.5.0

func (c *CompositeConsumerUpdater) SetOnStreamMissingError(fn func(streamName string, err error))

SetOnStreamMissingError records the callback and forwards it to all current children that implement recovery.StreamMissingObserver. Children that do not implement the interface are silently skipped. Subsequent [Add] calls forward the recorded observer to newly-added observer-capable children.

Passing fn == nil clears the recorded observer and forwards the nil to current observer-capable children so they stop dispatching to the previous closure.

Implements recovery.StreamMissingObserver; the Parti manager type-asserts the composite during Manager.Start and bridges the stream-missing recovery exhaustion event to enterDegraded( "stream-missing-recovery-exhausted").

func (*CompositeConsumerUpdater) UpdateWorkerConsumer

func (c *CompositeConsumerUpdater) UpdateWorkerConsumer(ctx context.Context, workerID string, partitions []Partition) error

UpdateWorkerConsumer implements WorkerConsumerUpdater. Calls UpdateWorkerConsumer on all registered updaters, collecting any errors. All updaters are called even if some fail.

type Config

type Config struct {
	// WorkerIDPrefix is the prefix for worker IDs (e.g., "worker" produces "worker-0", "worker-1").
	WorkerIDPrefix string `yaml:"workerIdPrefix" default:"worker" validate:"required"`

	// WorkerLabels is this worker's label set, fixed at process startup.
	// Labeled partitions (Partition.Label) are assigned only to workers
	// whose set contains the partition's label. Validated with
	// partition-key charset rules; sorted and deduplicated. At most 16
	// labels, each at most 64 bytes. Empty = unlabeled worker.
	// WithWorkerLabels overrides this field when both are set.
	WorkerLabels []string `yaml:"workerLabels"`

	// UnlabeledPartitionPolicy controls which workers receive unlabeled
	// partitions. "dedicated" (default): unlabeled workers only, falling
	// back to all workers when no unlabeled worker is live. "shared":
	// all workers. Leader-side; MUST be identical across every manager
	// in the fleet (same contract as the AssignmentStrategy choice).
	UnlabeledPartitionPolicy string `yaml:"unlabeledPartitionPolicy" default:"dedicated" validate:"oneof=dedicated shared"`

	// LabelSpillGrace is how long a label's worker pool must be
	// continuously empty before its partitions spill to the fallback
	// ladder. 0 spills immediately. Leader-side; MUST be fleet-uniform.
	LabelSpillGrace time.Duration `yaml:"labelSpillGrace" default:"60s" validate:"gte=0"`

	// WorkerIDMin is the minimum stable ID number (inclusive).
	// Set to 0 for most use cases.
	WorkerIDMin int `yaml:"workerIdMin" default:"0" validate:"gte=0"`

	// WorkerIDMax is the maximum stable ID number (inclusive).
	// Determines the maximum number of concurrent workers: (WorkerIDMax - WorkerIDMin + 1).
	// For example, WorkerIDMin=0 and WorkerIDMax=999 allows up to 1000 workers.
	WorkerIDMax int `yaml:"workerIdMax" default:"999" validate:"gtefield=WorkerIDMin"`

	// WorkerIDTTL is how long a worker ID claim remains valid in the key-value store.
	// Must be >= HeartbeatTTL (validated via the `gtefield=HeartbeatTTL` tag below;
	// setting WorkerIDTTL below HeartbeatTTL causes Manager.Start to fail
	// validation). Renewal writes one KV op per worker every
	// WorkerIDTTL/3, so longer values reduce steady-state IOPS at the cost of slower
	// ID reclamation after a worker exits.
	// Recommended: 3-5x HeartbeatTTL. Default 75s is 5x the default HeartbeatTTL (15s).
	// The stableID KV bucket's MaxAge is reconciled to exactly WorkerIDTTL on
	// Manager.Start: an existing bucket whose TTL differs (most dangerously 0,
	// which leaks worker IDs on ungraceful restart) is corrected, and a
	// deliberately-longer operator TTL is shortened to WorkerIDTTL.
	WorkerIDTTL time.Duration `yaml:"workerIdTtl" default:"75s" validate:"gt=0,gtefield=HeartbeatTTL"`

	// HeartbeatInterval is how often workers publish heartbeat messages.
	// Shorter intervals provide faster failure detection but increase KV write load
	// (one write per worker per interval, amplified by JetStream replication and fsync).
	// Recommended: 3-10 seconds.
	HeartbeatInterval time.Duration `yaml:"heartbeatInterval" default:"5s" validate:"gt=0"`

	// HeartbeatTTL is how long heartbeat messages remain valid before a worker is considered failed.
	// Must be greater than HeartbeatInterval.
	// Recommended: 3x HeartbeatInterval.
	HeartbeatTTL time.Duration `yaml:"heartbeatTtl" default:"15s" validate:"gt=0"`

	// ColdStartWindow is the stabilization period when starting workers from zero.
	// During this window, partition assignment is delayed to allow all initial workers to join.
	// Recommended: 30 seconds.
	ColdStartWindow time.Duration `yaml:"coldStartWindow" default:"30s" validate:"gt=0,gtefield=PlannedScaleWindow"`

	// PlannedScaleWindow is the stabilization period during rolling updates or planned scaling.
	// Shorter than ColdStartWindow to minimize disruption during controlled changes.
	// Recommended: 10 seconds.
	PlannedScaleWindow time.Duration `yaml:"plannedScaleWindow" default:"10s" validate:"gt=0"`

	// EmergencyGracePeriod is the minimum time a worker must be missing before
	// triggering emergency rebalance. Prevents false positives from transient
	// network issues or brief connectivity loss.
	//
	// Default: 0 (auto-calculated as 1.5 * HeartbeatInterval)
	// Recommended: 1.5-2.0 * HeartbeatInterval
	// Constraint: Must be <= HeartbeatTTL
	EmergencyGracePeriod time.Duration `yaml:"emergencyGracePeriod" validate:"ltefield=HeartbeatTTL"`

	// RestartDetectionRatio historically classified a restart as cold start vs
	// planned (if failed/total > ratio, treat as cold start).
	//
	// NOTE: this knob is currently a NO-OP. The cold-start-vs-planned-scale window
	// selector that consumed it was removed as dead code, so the value is still
	// accepted and validated for config compatibility but no longer affects
	// behavior. Its removal is deferred to a future major version (public-API change).
	RestartDetectionRatio float64 `yaml:"restartDetectionRatio" default:"0.5" validate:"gte=0,lte=1"`

	// OperationTimeout is the timeout for KV operations (get, put, delete).
	// Recommended: 10 seconds.
	OperationTimeout time.Duration `yaml:"operationTimeout" default:"10s" validate:"gt=0"`

	// BucketEpochProbeInterval is how often the bucket-epoch fence (see
	// monitorBucketEpochs) polls each Parti-owned KV bucket for a
	// wipe-and-recreate event. This was previously coupled to
	// OperationTimeout — the same knob that bounds every individual KV
	// operation's deadline — so changing one unintentionally changed the
	// other. This field decouples the probe cadence from that deadline.
	// Default 10s preserves the prior default behavior for deployments
	// that left OperationTimeout at its own 10s default; deployments that
	// TUNED OperationTimeout and relied on the old coupled cadence must set
	// this field to their previous OperationTimeout value to preserve it.
	// The per-probe deadline is still OperationTimeout (see
	// probeBucketCreated); this field controls only how often a probe pass
	// runs.
	BucketEpochProbeInterval time.Duration `yaml:"bucketEpochProbeInterval" default:"10s" validate:"gt=0"`

	// ElectionTimeout is the maximum time to wait for leader election to complete.
	// Also drives leader lease renewal frequency (ElectionTimeout/3). Longer values
	// reduce KV write load at the cost of slower failover detection.
	// Recommended: 5-15 seconds.
	ElectionTimeout time.Duration `yaml:"electionTimeout" default:"10s" validate:"gt=0"`

	// StartupTimeout is the maximum time to wait for the manager to fully start.
	// Includes worker ID claiming, leader election, and initial partition assignment.
	// Must be at least ColdStartWindow + ElectionTimeout with some headroom.
	// Recommended: 60 seconds.
	StartupTimeout time.Duration `yaml:"startupTimeout" default:"60s" validate:"gt=0"`

	// ShutdownTimeout is the maximum time to wait for graceful shutdown.
	// Includes releasing worker ID, stopping heartbeats, and cleanup operations.
	// Recommended: 10 seconds.
	ShutdownTimeout time.Duration `yaml:"shutdownTimeout" default:"10s" validate:"gt=0"`

	// RebalanceCooldown is the minimum time between rebalancing operations.
	//
	// Enforces rate limiting BEFORE stabilization windows to prevent thrashing
	// during rapid topology changes. If a rebalance was completed <RebalanceCooldown
	// ago, new topology changes are deferred until the interval expires.
	//
	// Default: 10 seconds
	// Recommendation: Should be <= PlannedScaleWindow for proper coordination
	//
	// Note: This was renamed from MinRebalanceInterval in v0.x for semantic clarity.
	RebalanceCooldown time.Duration `yaml:"rebalanceCooldown" default:"10s" validate:"gt=0,ltefield=PlannedScaleWindow"`

	// KVBuckets controls NATS JetStream KV bucket configuration.
	KVBuckets KVBucketConfig `yaml:"kvBuckets"`

	// DegradedAlert controls alert emission during degraded mode operation.
	DegradedAlert DegradedAlertConfig `yaml:"degradedAlert"`

	// DegradedBehavior controls when the manager enters and exits degraded mode.
	DegradedBehavior DegradedBehaviorConfig `yaml:"degradedBehavior"`

	// EnableTwoPhaseHandoff gates the manager-side two-phase handoff coordinator.
	//
	// When false (default), assignment changes are applied directly via the
	// WorkerConsumerUpdater in a simple "remove/add" sequence managed by the
	// manager without KV claims. This keeps the control plane minimal but can
	// permit a brief duplicate-consumption window during reassignment.
	//
	// When true, the manager initializes a modular handoff coordinator which
	// can implement a prepare/commit protocol to make ownership transitions
	// atomic, single-owner, auditable, and crash-resumable. This feature is
	// wired behind a clean interface and can be enabled/disabled without
	// scattering conditional logic throughout the manager code path.
	EnableTwoPhaseHandoff bool `yaml:"enableTwoPhaseHandoff" default:"false"`

	// Handoff controls two-phase handoff tuning knobs.
	// Only applied when EnableTwoPhaseHandoff is true.
	Handoff HandoffConfig `yaml:"handoff"`

	// ApplyStartJitter, when > 0, randomly delays fresh-version applies
	// (watcher / commit / alias / initial-bootstrap, all funneled through
	// applyAssignmentWithPrev) by a uniformly distributed duration in
	// [0, ApplyStartJitter) before taking applyStoreMu. This spreads
	// JetStream consumer create/destroy load across a worker fleet that
	// observed the same new assignment version simultaneously (e.g. after
	// a leader re-election).
	//
	// Retries (scheduleApplyRetry) do NOT pay this jitter. The retry path
	// has its own exponential-backoff envelope with decorrelated jitter;
	// compounding apply-start jitter on top would inflate recovery latency
	// without spreading any fleet because a retry is one worker, not a fleet.
	//
	// Default 0 disables jitter (backwards compatible). Recommended starting
	// point for a 20-worker fleet creating 100 consumers each: 500ms.
	//
	// Hard-capped at 10s by Validate(): a larger value would dwarf the
	// LSR-advancement bookkeeping and risk masking apply failures behind
	// what would look like apply latency.
	//
	// Startup-runner consequence: the startup background runner's first
	// apply (applyInitialAssignment, manager_startup_async.go) also funnels
	// through applyAssignmentWithPrev for non-empty commits/aliases. With
	// jitter enabled, the runner sleeps up to ApplyStartJitter before its
	// first apply attempt. The soft watchdog measures StartupTimeout from
	// Start invocation (AGENTS.md "Apply boundedness" section), so operators
	// MUST configure ApplyStartJitter << StartupTimeout. Recommended:
	// ApplyStartJitter <= StartupTimeout / 4. A focused test pins this
	// (TestApplyStartJitter_StartupBudget).
	ApplyStartJitter time.Duration `yaml:"applyStartJitter" default:"0" validate:"gte=0"`

	// AssignmentWatcherDebounce is the idle-window duration used to coalesce
	// rapid bursts of assignment delivery watcher events into a single apply.
	// It applies to both the legacy per-worker assignment alias watcher and
	// the assignment._commit watcher. When > 0, each watcher path keeps the
	// latest observed assignment target in a pending slot and processes it
	// after the stream has been idle for the full window.
	//
	// Early-flush conditions (commit watcher only): the pending commit is
	// flushed BEFORE the idle window elapses when (a) the watcher channel
	// closes mid-window, or (b) a same-or-higher commit arrives that
	// changes this worker's effective partition set — either a Workers
	// membership flip (this worker added or removed) or a
	// Payloads[workerID].PayloadHash difference. Both shapes are treated
	// as ownership transitions; debounce collapses only commits whose
	// per-worker partition set is unchanged. This preserves the two-phase
	// handoff invariant that every ownership transition for this worker is
	// observed as a discrete apply. Stale lower-version commits arriving
	// after a higher pending version are dropped without flushing pending.
	// On context cancellation (Stop), the pending commit is dropped
	// without flushing — see the alias watcher's matching shutdown
	// semantics. The legacy alias watcher does NOT yet have the "flush on
	// assignment change" rule; harmonization is a follow-up.
	//
	// Default 0 disables debouncing (preserves pre-PR-3 behavior). Recommended
	// value derived from the apply-coalescing diagnostic
	// (PARTI_RUN_HERD_DIAGNOSTIC=1) plus a 50 ms safety margin — typically
	// in the 100–300 ms range.
	//
	// Hard-capped at 1 second by Validate(): a larger window would dwarf
	// reasonable reassignment-latency budgets and risk masking real apply
	// failures behind what looks like apply slowness.
	AssignmentWatcherDebounce time.Duration `yaml:"assignmentWatcherDebounce" default:"0" validate:"gte=0"`
}

Config is the configuration for the Manager.

All duration fields accept standard Go duration strings like "30s", "5m", "1h".

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns a Config with sensible defaults.

This function panics only if the library's own struct tags are malformed, which indicates a programming error in Parti itself.

Returns:

  • Config: Configuration with default values
Example

This example demonstrates using DefaultConfig as a starting point and customizing specific fields.

package main

import (
	"fmt"
	"time"

	"github.com/arloliu/parti/v2"
)

func main() {
	cfg := parti.DefaultConfig()

	// Override only what you need
	cfg.WorkerIDPrefix = "my-service"
	cfg.WorkerIDMax = 50
	cfg.HeartbeatInterval = 5 * time.Second
	cfg.HeartbeatTTL = 15 * time.Second

	fmt.Printf("Prefix: %s, MaxWorkers: %d, HeartbeatInterval: %v, HeartbeatTTL: %v\n",
		cfg.WorkerIDPrefix, cfg.WorkerIDMax+1, cfg.HeartbeatInterval, cfg.HeartbeatTTL)
}
Output:
Prefix: my-service, MaxWorkers: 51, HeartbeatInterval: 5s, HeartbeatTTL: 15s

func TestConfig

func TestConfig() Config

TestConfig returns a configuration optimized for fast test execution.

Test timings are 10-100x faster than production defaults to enable rapid iteration without sacrificing test coverage. Use DefaultConfig() for production deployments.

Returns:

  • Config: Configuration with fast timings for tests

Example:

cfg := parti.TestConfig()
cfg.WorkerIDPrefix = "test-worker"
manager, err := parti.NewManager(nc, cfg)

func (*Config) Validate

func (cfg *Config) Validate() error

Validate checks configuration constraints and returns error for invalid values.

Hard Validation Rules:

  • HeartbeatTTL >= 2 * HeartbeatInterval (allow 1 missed heartbeat)
  • WorkerIDTTL >= 3 * HeartbeatInterval (stable ID renewal)
  • WorkerIDTTL >= HeartbeatTTL (ID must outlive heartbeat)
  • WorkerIDTTL >= 300ms (stable-ID renewal floor)
  • RebalanceCooldown > 0 (prevent thrashing)
  • ColdStartWindow >= PlannedScaleWindow (cold start is slower)
  • RebalanceCooldown <= PlannedScaleWindow (rate limit coordination)
  • RebalanceCooldown <= ColdStartWindow (rate limit coordination)
  • EmergencyGracePeriod <= HeartbeatTTL (detection window)

Returns:

  • error: Validation error with clear explanation, nil if valid

func (*Config) ValidateWithWarnings

func (cfg *Config) ValidateWithWarnings(logger Logger)

ValidateWithWarnings checks configuration and logs warnings for non-recommended values.

This is called after Validate() in NewManager() to provide operator guidance.

Parameters:

  • logger: Logger instance for warning output

type DegradedAlertConfig

type DegradedAlertConfig struct {
	// InfoThreshold is the duration in degraded mode before emitting Info-level alerts.
	// Default: 30 seconds.
	InfoThreshold time.Duration `yaml:"infoThreshold" default:"30s" validate:"gt=0"`

	// WarnThreshold is the duration in degraded mode before emitting Warn-level alerts.
	// Default: 2 minutes.
	WarnThreshold time.Duration `yaml:"warnThreshold" default:"2m" validate:"gt=0,gtefield=InfoThreshold"`

	// ErrorThreshold is the duration in degraded mode before emitting Error-level alerts.
	// Default: 5 minutes.
	ErrorThreshold time.Duration `yaml:"errorThreshold" default:"5m" validate:"gt=0,gtefield=WarnThreshold"`

	// CriticalThreshold is the duration in degraded mode before emitting Critical-level alerts.
	// Default: 10 minutes.
	CriticalThreshold time.Duration `yaml:"criticalThreshold" default:"10m" validate:"gt=0,gtefield=ErrorThreshold"`

	// AlertInterval is the time between repeated alerts at the same severity level.
	// Default: 1 minute.
	AlertInterval time.Duration `yaml:"alertInterval" default:"1m" validate:"gt=0"`
}

DegradedAlertConfig controls alert emission during degraded mode operation.

type DegradedBehaviorConfig

type DegradedBehaviorConfig struct {
	// EnterThreshold is how long NATS connectivity errors must persist before entering degraded mode.
	// Provides hysteresis to prevent flapping during transient issues.
	// Default: 10 seconds.
	EnterThreshold time.Duration `yaml:"enterThreshold" default:"10s" validate:"gte=0"`

	// ExitThreshold is how long NATS connectivity must be stable before exiting degraded mode.
	// Should be shorter than EnterThreshold to recover quickly.
	// Default: 5 seconds.
	ExitThreshold time.Duration `yaml:"exitThreshold" default:"5s" validate:"gte=0"`

	// KVErrorThreshold is the number of KV operation errors within KVErrorWindow
	// that trigger degraded mode. Whole-bucket-loss errors accumulate across the
	// window; connected-but-KV-unavailable timeouts are reset by any successful
	// periodic KV op while not degraded, so for that class it is a consecutive
	// run with no intervening success rather than blips summed across the window.
	// Default: 5 errors.
	KVErrorThreshold int `yaml:"kvErrorThreshold" default:"5" validate:"gte=0"`

	// KVErrorWindow is the time window for counting KV errors toward
	// KVErrorThreshold. Errors outside this window are not counted.
	// Default: 30 seconds.
	KVErrorWindow time.Duration `yaml:"kvErrorWindow" default:"30s" validate:"gte=0"`

	// RecoveryGracePeriod is the minimum time the leader must wait after recovering from
	// degraded mode before declaring missing workers as failed (emergency rebalance).
	// Prevents false emergencies when workers recover slightly slower than the leader.
	// Default: 15 seconds.
	RecoveryGracePeriod time.Duration `yaml:"recoveryGracePeriod" default:"15s" validate:"gte=0"`
}

DegradedBehaviorConfig controls when the manager enters and exits degraded mode.

func DegradedBehaviorPreset

func DegradedBehaviorPreset(preset string) (DegradedBehaviorConfig, error)

DegradedBehaviorPreset returns a preconfigured DegradedBehaviorConfig based on the preset name.

Supported presets:

  • "conservative": Slower to enter degraded, safer for production (30s enter, 10s exit, 10 errors)
  • "balanced": Default behavior, good for most use cases (10s enter, 5s exit, 5 errors)
  • "aggressive": Faster to enter degraded, better for development (5s enter, 3s exit, 3 errors)

Parameters:

  • preset: Preset name ("conservative", "balanced", or "aggressive")

Returns:

  • DegradedBehaviorConfig: Preconfigured behavior settings
  • error: ErrInvalidPreset if preset name is not recognized

Example:

cfg, err := DegradedBehaviorPreset("conservative")
if err != nil {
    log.Fatal(err)
}

type ElectionAgent

type ElectionAgent = types.ElectionAgent

Re-export interfaces from the internal types package for convenience.

type FleetSizeObserver added in v2.8.0

type FleetSizeObserver interface {
	// ObserveWorkerCount reports the current committed cluster worker-count.
	ObserveWorkerCount(n int)
}

FleetSizeObserver is an optional interface a WorkerConsumerUpdater MAY implement to receive the observed cluster worker-count (N) for fleet-size- aware (adaptive) rate limiting.

When the registered updater (or any child of a composite updater) satisfies this interface, the Manager calls ObserveWorkerCount whenever the committed worker-set size changes — and once during startup, before the first apply — so the consumer can retune its consumer-create rate to min(perWorkerCeiling, clusterRate/N).

Implementations MUST be:

  • Non-blocking. ObserveWorkerCount runs on the Manager's commit-watch goroutine while the Manager holds an internal lock; it must not perform I/O or block.
  • Safe for concurrent use.
  • Non-reentrant: it MUST NOT call back into the Manager or any apply/update path (mirrors the CapabilityReporter contract and the D5 lock-order rule).

n is always >= 1.

type HandoffClaim

type HandoffClaim struct {
	PartitionID   string            `json:"partition_id"`
	Owner         string            `json:"owner"`
	PendingOwner  string            `json:"pending_owner,omitempty"`
	State         HandoffClaimState `json:"state"`
	Epoch         int64             `json:"epoch"`
	LastUpdated   time.Time         `json:"last_updated"`
	TTLSeconds    int64             `json:"ttl_seconds"`
	ConflictCount int64             `json:"conflict_count,omitempty"`
}

HandoffClaim is a read-only view of a partition handoff claim stored in the two-phase handoff KV bucket. It is intentionally decoupled from internal implementation details and suitable for diagnostics and tests.

func InspectHandoffClaims

func InspectHandoffClaims(ctx context.Context, js jetstream.JetStream, bucket string) ([]HandoffClaim, error)

InspectHandoffClaims opens the provided JetStream KV bucket and returns all current handoff claims stored under the "claims/" prefix.

This helper is public to allow tests to inspect claims without a Manager instance, given a JetStream context and bucket name.

Parameters:

  • ctx: Context for cancellation
  • js: JetStream context used to open the bucket
  • bucket: KV bucket name where handoff claims are stored

Returns:

  • []HandoffClaim: Decoded claims
  • error: Error opening the bucket or reading/decoding entries

type HandoffClaimState

type HandoffClaimState string

HandoffClaimState represents the logical state of a handoff claim.

Values:

  • "stable": No handoff in progress
  • "prepare": Target worker declared intent to take over
  • "commit": Ownership switch in-progress, awaiting final stabilization
  • "abort": Handoff aborted (future use)
  • "unknown": Parsing fallback
const (
	HandoffClaimStable  HandoffClaimState = "stable"
	HandoffClaimPrepare HandoffClaimState = "prepare"
	HandoffClaimCommit  HandoffClaimState = "commit"
	HandoffClaimAbort   HandoffClaimState = "abort"
	HandoffClaimUnknown HandoffClaimState = "unknown"
)

type HandoffConfig

type HandoffConfig struct {
	// SweepInterval controls how often stale/expired claims are opportunistically
	// swept during Apply calls. If zero or negative, a sweep is attempted on every Apply.
	SweepInterval time.Duration `yaml:"sweepInterval" default:"30s" validate:"gte=0"`

	// MaxRetries controls bounded CAS retries for claim updates. Zero uses default (3).
	MaxRetries int `yaml:"maxRetries" default:"3" validate:"gte=0"`

	// BaseBackoff is the initial backoff for CAS retry with exponential backoff.
	BaseBackoff time.Duration `yaml:"baseBackoff" default:"50ms" validate:"gte=0"`

	// MaxBackoff caps the exponential backoff duration.
	MaxBackoff time.Duration `yaml:"maxBackoff" default:"500ms" validate:"gte=0,gtefield=BaseBackoff"`

	// Jitter is a fractional value [0.0, 1.0] to randomize backoff durations.
	Jitter float64 `yaml:"jitter" default:"0.2" validate:"gte=0,lte=1"`

	// DelayAfterPrepare introduces an artificial delay after the prepare phase completes
	// and before the consumer updater is invoked. Useful for making intermediate states
	// observable in tests and demonstrations. Ignored if <= 0.
	DelayAfterPrepare time.Duration `yaml:"delayAfterPrepare" default:"0" validate:"gte=0"`

	// DelayBeforeStable introduces an artificial delay after entering the commit state
	// and before finalizing to stable. Useful for external observation of the commit state.
	// Ignored if <= 0.
	DelayBeforeStable time.Duration `yaml:"delayBeforeStable" default:"0" validate:"gte=0"`

	// PhaseConcurrency caps the number of in-flight per-partition KV operations
	// during each of the prepare, commit, and stabilize phases. Zero uses the
	// default of 20. Note: 0 means "use default", not "no parallelism"; set 1
	// for strictly serial execution.
	PhaseConcurrency int `yaml:"phaseConcurrency" default:"0" validate:"gte=0,lte=256"`

	// ClaimWritePerSec optionally enables a per-worker token-bucket rate limit
	// on every physical handoff claim-write (the KV PutIfEpoch the coordinator
	// and startup loops issue), measured in writes/second. It paces the
	// otherwise-unbounded startup hygiene/resume loops (which walk every claim
	// key sequentially) and the two-phase coordinator's per-partition CAS
	// writes — including CAS retries — so a large-fleet restart or rapid
	// rebalance cannot drive a back-to-back KV write burst that stresses the
	// NATS cluster. PhaseConcurrency bounds simultaneity; this bounds rate.
	//
	// Opt-in: 0 (the default) disables rate limiting and leaves behaviour
	// unchanged. A single budget is shared across all claim-write sites for
	// this worker. Must be >= 0.
	//
	// Note: the startup hygiene pass runs under OperationTimeout on the
	// synchronous Manager.Start path, so a low rate combined with many
	// pre-existing stale claims can extend Start's blocking return latency up
	// to OperationTimeout before the best-effort pass stops early; the
	// background resume pass and the coordinator are not OperationTimeout-bounded
	// and run off the Start path. Sizing guidance lives in docs/OPERATIONS.md.
	ClaimWritePerSec float64 `yaml:"claimWritePerSec" default:"0" validate:"gte=0"`

	// ClaimWriteBurst is the token-bucket burst size for ClaimWritePerSec. It
	// must be >= 1 when ClaimWritePerSec > 0 (rejected at Validate otherwise);
	// it is ignored when ClaimWritePerSec == 0. Recommended starting point:
	// burst ≈ PhaseConcurrency so a single rebalance wave is absorbed without
	// throttling and only sustained bursts are paced.
	ClaimWriteBurst int `yaml:"claimWriteBurst" default:"0" validate:"gte=0"`

	// ClaimWriteClusterRate, when > 0, makes claim-write rate limiting
	// fleet-size-aware: each worker enforces
	// min(ClaimWritePerSec, ClaimWriteClusterRate/N), where N is the observed
	// committed worker count. It bounds the STEADY-STATE cluster-wide
	// claim-write rate to ClaimWriteClusterRate instead of N*ClaimWritePerSec.
	// Requires ClaimWritePerSec > 0 (the per-worker ceiling and burst source)
	// and EnableTwoPhaseHandoff. Default 0 = static per-worker rate only.
	ClaimWriteClusterRate float64 `yaml:"claimWriteClusterRate" default:"0" validate:"gte=0"`
}

HandoffConfig controls two-phase handoff coordinator behavior.

These settings are only used when EnableTwoPhaseHandoff is true.

type HandoffMetricsRecorder

type HandoffMetricsRecorder = types.HandoffMetricsRecorder

Re-export interfaces from the internal types package for convenience.

type HandoffSweepMetricsRecorder added in v2.10.1

type HandoffSweepMetricsRecorder = types.HandoffSweepMetricsRecorder

HandoffSweepMetricsRecorder is the optional claim-sweep observability capability a HandoffMetricsRecorder may additionally implement; see types.HandoffSweepMetricsRecorder for the label contract.

type Hooks

type Hooks = types.Hooks

Re-export interfaces from the internal types package for convenience.

type KVBucketConfig

type KVBucketConfig struct {
	// StableIDBucket is the bucket name for stable worker ID claims.
	StableIDBucket string `yaml:"stableIdBucket" default:"parti-stableid" validate:"required"`

	// ElectionBucket is the bucket name for leader election.
	ElectionBucket string `yaml:"electionBucket" default:"parti-election" validate:"required"`

	// HeartbeatBucket is the bucket name for worker heartbeats.
	HeartbeatBucket string `yaml:"heartbeatBucket" default:"parti-heartbeat" validate:"required"`

	// AssignmentBucket is the bucket name for partition assignments.
	AssignmentBucket string `yaml:"assignmentBucket" default:"parti-assignment" validate:"required"`

	// AssignmentTTL is how long assignments remain in KV (0 = no expiration).
	// Assignments should persist across leader changes for version continuity.
	// Recommended: 0 (no TTL) or very long (e.g., 1 hour).
	AssignmentTTL time.Duration `yaml:"assignmentTtl" default:"0" validate:"gte=0"`

	// HandoffBucket is the bucket name for two-phase handoff ownership claims.
	// Used only when EnableTwoPhaseHandoff is true. Stores per-partition claim
	// records that track prepare/commit/stable transitions to ensure atomic
	// ownership changes and crash-resumable state. Kept separate from assignment
	// data to allow independent TTL and operational policies (sweeps, compaction).
	// Recommended: distinct bucket to isolate churn from stable assignment data.
	HandoffBucket string `yaml:"handoffBucket" default:"parti-handoff"`

	// HandoffTTL is the advisory staleness threshold the two-phase handoff
	// coordinator uses to recover a STUCK in-flight handoff: the periodic claim
	// sweep resets a prepare/commit claim back to stable once it has gone
	// untouched for longer than HandoffTTL.
	//
	// It is NOT a KV bucket TTL. The handoff bucket is created with no MaxAge —
	// a bucket-level TTL would also age out healthy stable ownership claims
	// (which are written once and never refreshed), permanently suppressing
	// pull-gated consumers. Stable claims therefore never expire.
	//
	// Must be longer than the expected multi-phase handoff duration (including
	// retries). Recommended: 2-5 minutes in production; fast tests may use
	// seconds.
	HandoffTTL time.Duration `yaml:"handoffTtl" default:"2m"`

	// Replicas is the JetStream stream-replication factor applied to
	// Parti-owned KV buckets when the library creates them fresh. Zero
	// (the default) leaves the field unset, which nats.go normalizes to 1
	// server-side — equivalent to no replication, the legacy behavior.
	//
	// Not to be confused with worker-pod replicas: Parti does not
	// configure pod counts (that is the orchestrator's concern). This
	// setting controls JetStream-level data replication only.
	//
	// Applies uniformly to every Parti-owned bucket the library creates
	// (stableID, election, heartbeat, assignment, handoff). Per-bucket
	// precision requires pre-creating buckets with explicit configs (the
	// provision package supports this) — pre-created buckets keep their
	// existing replica count regardless of this setting, because the
	// library's ensure path is get-first.
	//
	// Post-create modification: NATS JetStream DOES support changing the
	// replica count on an existing bucket via `UpdateStream`, but Parti
	// does NOT auto-reconcile Replicas. Unlike MaxAge (which is reconciled
	// because it carries a correctness invariant), Replicas is a
	// per-deployment HA-quality decision; silently rewriting an existing
	// bucket's Replicas could trigger expensive cross-node replication
	// the operator did not ask for, or mask a deliberate downsize. When
	// Manager.Start observes a pre-existing bucket whose Replicas differs
	// from this setting, it emits a WARN log line naming the divergence
	// and pointing at the `nats stream update KV_<bucket> --replicas=<N>`
	// command operators can run to apply the change themselves.
	//
	// Cluster topology must support the value at create time or bucket
	// creation fails loudly at Manager.Start (e.g. Replicas=3 against a
	// single-node NATS server returns a stream-creation error).
	//
	// Recommended values:
	//   - 0 / 1 — single-node NATS or test environments (default)
	//   - 3   — production multi-node cluster (minimum for quorum)
	//   - 5   — high-availability tier
	Replicas int `yaml:"replicas,omitempty" default:"0" validate:"gte=0"`
}

KVBucketConfig configures NATS JetStream KV bucket names and TTLs.

type LabelState added in v2.10.1

type LabelState struct {
	// PoolSizes maps each label present in the last published rebalance to
	// the number of live workers eligible for it.
	PoolSizes map[string]int

	// Parked maps each such label to the number of partitions currently
	// parked (intentionally left unassigned) for it.
	Parked map[string]int
}

LabelState is a point-in-time, leader-computed snapshot of label-mode observability state, exposed pull-style so deployments without a MetricsCollector get baseline label visibility ("are any VIP partitions parked right now?") from a health endpoint or log line. It complements, and does not replace, the push types.LabelMetrics interface.

type Logger

type Logger = types.Logger

Re-export interfaces from the internal types package for convenience.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager coordinates workers in a distributed system for partition-based work distribution.

Manager is the main entry point of the Parti library. It handles:

  • Stable worker ID claiming using NATS KV
  • Leader election for assignment coordination
  • Partition assignment calculation and distribution
  • Heartbeat publishing and failure detection
  • Graceful rebalancing during scaling events

Thread Safety:

  • All public methods are safe for concurrent use
  • State transitions are atomic and linearizable
  • Assignment updates are copy-on-write

Lifecycle:

  • Create with NewManager()
  • Call Start() to claim ID and begin coordination
  • Use hooks to react to assignment changes
  • Call Stop() for graceful shutdown

Testing: Consumers can define minimal interfaces for mocking:

type WorkCoordinator interface {
    Start(ctx context.Context) error
    WorkerID() string
}

func NewManager

func NewManager(cfg *Config, js jetstream.JetStream, source PartitionSource, strategy AssignmentStrategy, opts ...Option) (*Manager, error)

NewManager creates a new Manager instance with the provided configuration.

The Manager coordinates workers in a distributed system using NATS for:

  • Stable worker ID claiming (via NATS KV)
  • Leader election for assignment coordination
  • Partition assignment distribution
  • Heartbeat publication for health monitoring

Returns a concrete *Manager struct following the "accept interfaces, return structs" principle. Consumers can define their own interfaces for testing if needed.

Internal components (calculator, heartbeat, election, claimer) are initialized with NoOp implementations, ensuring they are never nil.

Parameters:

  • cfg: Configuration for the manager
  • js: JetStream context for NATS interaction
  • source: Source of partitions to distribute
  • strategy: Strategy for assigning partitions to workers
  • opts: Optional configuration options

Returns:

  • *Manager: Initialized manager instance
  • error: Validation error if configuration is invalid

Example:

cfg := parti.Config{WorkerIDPrefix: "worker", WorkerIDMax: 999}
src := source.NewStatic(partitions)
curStrategy := strategy.NewConsistentHash()
js, _ := jetstream.New(natsConn)
mgr, err := parti.NewManager(&cfg, js, src, curStrategy)
if err != nil {
    log.Fatal(err)
}
Example

This example demonstrates the basic usage of NewManager with default settings. In production, obtain js from nats.Connect + jetstream.New.

package main

import (
	"context"
	"fmt"

	"github.com/arloliu/parti/v2"
	"github.com/arloliu/parti/v2/source"
	"github.com/arloliu/parti/v2/strategy"
	"github.com/nats-io/nats.go/jetstream"
)

func main() {
	// In production, obtain from nats.Connect + jetstream.New
	var js jetstream.JetStream

	cfg := &parti.Config{
		WorkerIDPrefix: "worker",
		WorkerIDMin:    0,
		WorkerIDMax:    999,
	}

	partitions := []parti.Partition{
		{Keys: []string{"orders", "0"}},
		{Keys: []string{"orders", "1"}},
		{Keys: []string{"orders", "2"}},
	}
	src := source.NewStatic(partitions)
	hashStrategy := strategy.NewConsistentHash()

	mgr, err := parti.NewManager(cfg, js, src, hashStrategy)
	if err != nil {
		return // requires live NATS; see integration tests for full examples
	}
	defer func() { _ = mgr.Stop(context.Background()) }()

	if err := mgr.Start(context.Background()); err != nil {
		// Always call Stop on Start failure to release resources.
		return
	}

	fmt.Printf("Worker started with ID: %s\n", mgr.WorkerID())
}
Example (WithHooks)

This example demonstrates using NewManager with hooks for lifecycle notifications.

package main

import (
	"context"
	"fmt"

	"github.com/arloliu/parti/v2"
	"github.com/arloliu/parti/v2/source"
	"github.com/arloliu/parti/v2/strategy"
	"github.com/nats-io/nats.go/jetstream"
)

func main() {
	var js jetstream.JetStream

	hooks := &parti.Hooks{
		OnAssignmentChanged: func(ctx context.Context, oldPartitions, newPartitions []parti.Partition) error {
			fmt.Printf("Assignment changed: %d -> %d partitions\n",
				len(oldPartitions), len(newPartitions))
			return nil
		},
		OnStateChanged: func(ctx context.Context, from, to parti.State) error {
			fmt.Printf("State: %s -> %s\n", from, to)
			return nil
		},
		OnLeadershipChanged: func(ctx context.Context, isLeader bool) error {
			fmt.Printf("Leadership changed: isLeader=%v\n", isLeader)
			return nil
		},
	}

	cfg := &parti.Config{
		WorkerIDPrefix: "worker",
		WorkerIDMax:    10,
	}
	src := source.NewStatic([]parti.Partition{{Keys: []string{"p0"}}})
	hashStrategy := strategy.NewConsistentHash()

	_, err := parti.NewManager(cfg, js, src, hashStrategy,
		parti.WithHooks(hooks),
	)
	if err != nil {
		return // requires live NATS; see integration tests for full examples
	}
}
Example (WithStrategy)

This example demonstrates using NewManager with a custom strategy.

package main

import (
	"github.com/arloliu/parti/v2"
	"github.com/arloliu/parti/v2/source"
	"github.com/arloliu/parti/v2/strategy"
	"github.com/nats-io/nats.go/jetstream"
)

func main() {
	var js jetstream.JetStream

	cfg := &parti.Config{
		WorkerIDPrefix: "processor",
		WorkerIDMax:    50,
	}

	partitions := []parti.Partition{
		{Keys: []string{"shard-0"}, Weight: 100},
		{Keys: []string{"shard-1"}, Weight: 200},
		{Keys: []string{"shard-2"}, Weight: 150},
	}
	src := source.NewStatic(partitions)

	// Use consistent hash with more virtual nodes for better distribution
	hashStrategy := strategy.NewConsistentHash(
		strategy.WithVirtualNodes(300),
	)

	_, err := parti.NewManager(cfg, js, src, hashStrategy)
	if err != nil {
		return // requires live NATS; see integration tests for full examples
	}
}

func (*Manager) Capabilities added in v2.4.0

func (m *Manager) Capabilities() uint32

Capabilities returns the current capability bitmask as an atomic snapshot.

The heartbeat publisher calls this on every heartbeat composition to embed the current runtime wire-up state. Do not cache the result — always read via this method so the heartbeat reflects live state.

Returns:

  • uint32: Current capability bitmask (OR of active types.CapXxx constants)

func (*Manager) CurrentAssignment

func (m *Manager) CurrentAssignment() Assignment

CurrentAssignment returns the current partition assignment for this worker.

The returned Assignment shares its Partitions backing array with the Manager's internal state. Callers MUST NOT modify the returned slice or its elements. If mutation is needed, make a copy first.

Returns:

  • Assignment: The current assignment. Returns empty assignment if none received.

func (*Manager) InspectHandoffClaims

func (m *Manager) InspectHandoffClaims(ctx context.Context) ([]HandoffClaim, error)

InspectHandoffClaims returns all current handoff claims from the configured handoff KV bucket for this Manager instance.

The method is best-effort and intended for integration tests and operational diagnostics. It requires two-phase handoff to be enabled and the handoff bucket to exist.

Parameters:

  • ctx: Context for cancellation

Returns:

  • []HandoffClaim: Decoded claim entries under the claims/ prefix
  • error: Any failure opening the bucket or listing/decoding entries

func (*Manager) IsInRecoveryGrace

func (m *Manager) IsInRecoveryGrace() bool

IsInRecoveryGrace returns true if currently in recovery grace period.

This is part of the StateProvider interface and allows components like Calculator to check recovery grace status without circular dependencies.

Returns:

  • bool: true if in recovery grace period

func (*Manager) IsLeader

func (m *Manager) IsLeader() bool

IsLeader returns whether this manager is currently the leader.

Returns:

  • bool: true if this manager is the leader, false otherwise.

func (*Manager) LabelState added in v2.10.1

func (m *Manager) LabelState() LabelState

LabelState returns the label snapshot from this node's last successfully published rebalance. Leader-only: non-leaders, leaders that have not yet published a rebalance, and stopped Managers return the zero value (nil maps). Use Manager.IsLeader to disambiguate "not the leader" from "leader with nothing labeled". The returned maps are fresh copies; the caller owns them and may retain or mutate them freely.

The snapshot is point-in-time: a leadership transition strictly concurrent with the call may race it, but once the transition is observable (IsLeader() reports false — the election loop flips the flag before any calculator teardown begins), the accessor returns the zero value.

Key-set parity with the push gauges: keys are exactly the labeled pools of the published decision (the unlabeled general pool is never a key), and a label with zero parked partitions is present with an explicit 0.

Returns:

  • LabelState: The snapshot, or the zero value when this node has none.

func (*Manager) RefreshPartitions

func (m *Manager) RefreshPartitions(ctx context.Context) error

RefreshPartitions triggers partition discovery refresh.

This method forces the partition source to be re-queried and, if the worker is the leader, triggers an immediate rebalance with the updated partition list. Non-leader workers will receive the updated assignments automatically.

Use this when:

  • Partitions are added/removed dynamically (e.g., Kafka topics, Redis shards)
  • You want to redistribute work after manual partition changes
  • Your partition source has changed but workers haven't detected it yet

Parameters:

  • ctx: Context for operation timeout

Returns:

  • error: Refresh error, or ErrNotStarted if manager isn't running

Example:

// After adding new partitions to your partition source
if err := manager.RefreshPartitions(ctx); err != nil {
    log.Printf("Failed to refresh partitions: %v", err)
}

func (*Manager) SetCapability added in v2.4.0

func (m *Manager) SetCapability(capBit uint32, active bool)

SetCapability sets or clears a single capability bit in the manager's heartbeat capability bitmask.

Called by the component that actually wires the corresponding safety mechanism — not by config-reading code. Examples:

  • The two-phase handoff coordinator calls SetCapability(types.CapTwoPhaseHandoff, true) after successfully starting, and (…, false) on Stop.
  • The consumer/updater reports CapProcessingGate via the optional CapabilityReporter interface; Manager samples it through reportConsumerCapabilities after every handoffCoordinator.Apply attempt and ORs the bit in here. The reporter pathway never clears bits; only other components may call SetCapability with active=false to clear.
  • The heartbeat publisher's CapAckV1 bit is set by startHeartbeat after the publisher starts successfully.

Parameters:

  • capBit: Capability bit to set or clear (e.g., types.CapAckV1)
  • active: true to set the bit, false to clear it

func (*Manager) Start

func (m *Manager) Start(ctx context.Context) (startErr error)

Start runs the manager's synchronous sanity-check phase: claim a stable worker ID, ensure required KV buckets exist, participate in election, start the heartbeat publisher, and start the calculator if elected leader. On success it transitions the manager to StateWaitingAssignment and spawns a background runner that attempts to fetch the initial assignment and apply it via the unified pipeline; on apply success the runner CAS-transitions to StateStable. A soft watchdog enters StateDegraded ("startup-timeout") if StartupTimeout (measured from Start invocation) elapses without reaching StateStable, signaling the readiness probe to rotate the pod.

Start returns once the sanity-check phase succeeds. The state observed after Start may be WaitingAssignment, Stable, or any calculator-driven active state (Scaling, Rebalancing, Emergency) depending on race. Callers that need to know the manager is ready to process work should call mgr.WaitState(StateStable, timeout).

The background runner is best-effort: on assignment-fetch or apply failure it logs and continues to monitor startup. Existing recovery mechanisms handle subsequent retries — the assignment watcher redelivers when the leader publishes; applyAssignmentWithPrev's scheduleApplyRetry re-attempts on apply failure; monitorNATSConnection drives attemptRecoveryFromDegraded on reconnect.

Apply boundedness: applyInitialAssignment internally calls handoffCoordinator.Apply(m.ctx, ...) which is unbounded per attempt (identical to pre-refactor Start). A stuck consumer updater can block the runner inside one apply attempt until Stop. The soft watchdog still fires enterDegraded("startup-timeout") in that case for probe-driven rotation.

Start returns an error only for synchronous-phase failures (bucket creation, ID claim, election RPC). Auto-cleanup invokes Stop on a non-nil error so callers do not need to call Stop after a failed Start.

Parameters:

  • ctx: Context for startup-phase timeout control (not manager lifetime)

Returns:

  • error: Synchronous-phase startup error or context cancellation

Example usage:

mgr := parti.NewManager(cfg, js, source, strategy)
if err := mgr.Start(ctx); err != nil {
    return err // no need to call Stop
}
if err := <-mgr.WaitState(parti.StateStable, 30*time.Second); err != nil {
    return err
}

func (*Manager) State

func (m *Manager) State() State

State returns the current state of the manager.

Returns:

  • State: The current state (e.g., StateInit, StateStable).

func (*Manager) Stop

func (m *Manager) Stop(ctx context.Context) error

Stop gracefully shuts down the manager.

Safe to call multiple times - subsequent calls will return ErrNotStarted.

Parameters:

  • ctx: Context for shutdown timeout

Returns:

  • error: Shutdown error or timeout

func (*Manager) WaitState

func (m *Manager) WaitState(expectedState State, timeout time.Duration) <-chan error

WaitState waits for the manager to reach the expected state within the timeout period.

This method is useful for testing and synchronization scenarios where you need to wait for the manager to reach a specific state before proceeding.

The method returns a read-only channel that will receive exactly one value:

  • nil if the expected state is reached within the timeout
  • context.DeadlineExceeded if the timeout expires before reaching the state

The channel is closed after sending the result, allowing safe use in select statements.

Parameters:

  • expectedState: The state to wait for
  • timeout: Maximum duration to wait for the state

Returns:

  • <-chan error: A channel that receives the result (nil on success, error on timeout)

Example:

// Wait for manager to reach Stable state
errCh := manager.WaitState(StateStable, 10*time.Second)
if err := <-errCh; err != nil {
    log.Printf("Failed to reach Stable state: %v", err)
}

// Using with select for multiple operations
select {
case err := <-manager.WaitState(StateStable, 5*time.Second):
    if err != nil {
        return fmt.Errorf("timeout waiting for stable state: %w", err)
    }
case <-ctx.Done():
    return ctx.Err()
}

// Waiting for multiple managers
for i, mgr := range managers {
    if err := <-mgr.WaitState(StateStable, 10*time.Second); err != nil {
        return fmt.Errorf("manager %d failed: %w", i, err)
    }
}

func (*Manager) WorkerID

func (m *Manager) WorkerID() string

WorkerID returns the stable worker ID claimed by this manager.

Returns:

  • string: The worker ID (e.g., "worker-0"), or empty string if not yet claimed.

func (*Manager) WorkerLabels added in v2.9.0

func (m *Manager) WorkerLabels() []string

WorkerLabels returns this worker's resolved label set (sorted, deduplicated, normalized at construction — see NewManager and normalizeWorkerLabels). Each call returns a fresh clone of the manager's internal label slice, so the caller owns the result and may freely mutate it without affecting the manager's internal state.

Returns:

  • []string: A fresh copy of this worker's label set, or nil if unlabeled.

type ManagerMetrics

type ManagerMetrics = types.ManagerMetrics

Re-export interfaces from the internal types package for convenience.

type MetricsCollector

type MetricsCollector = types.MetricsCollector

Re-export interfaces from the internal types package for convenience.

type Option

type Option func(*managerOptions)

Option configures a Manager with optional dependencies.

func WithBucketEpochProbeInterval added in v2.10.0

func WithBucketEpochProbeInterval(d time.Duration) Option

WithBucketEpochProbeInterval sets how often the bucket-epoch fence probes each Parti-owned KV bucket for a wipe-and-recreate event, overriding Config.BucketEpochProbeInterval — see that field's doc for the full cadence-vs-deadline rationale (OperationTimeout still bounds each individual probe).

A non-positive duration causes NewManager to return an error wrapping types.ErrInvalidConfig, matching the config field's gt=0 rule.

Parameters:

  • d: Probe interval (> 0)

Returns:

  • Option: Functional option for NewManager

func WithElectionAgent

func WithElectionAgent(agent ElectionAgent) Option

WithElectionAgent sets a custom election agent.

Parameters:

  • agent: ElectionAgent implementation

Returns:

  • Option: Functional option for NewManager

Example:

agent := myElectionAgent
js, _ := jetstream.New(conn)
mgr, err := parti.NewManager(&cfg, js, src, strategy.NewConsistentHash(), parti.WithElectionAgent(agent))
if err != nil { /* handle */ }

func WithHandoffMetricsRecorder

func WithHandoffMetricsRecorder(mr HandoffMetricsRecorder) Option

WithHandoffMetricsRecorder sets a specialized metrics recorder for the internal two-phase handoff coordinator.

Intended primarily for tests to assert CAS conflicts, phase timings, and sweeper behavior. In production, leave unset to use the default no-op or a future global wiring.

A recorder that additionally implements the optional HandoffSweepMetricsRecorder capability also receives per-origin claim-sweep pass counts; the coordinator type-asserts for it, so a recorder without the capability loses nothing else.

Parameters:

  • mr: HandoffMetricsRecorder implementation

Returns:

  • Option: Functional option for NewManager

func WithHooks

func WithHooks(hooks *Hooks) Option

WithHooks sets lifecycle event hooks.

Parameters:

  • hooks: Hooks structure with callback functions

Returns:

  • Option: Functional option for NewManager

Example:

hooks := &parti.Hooks{
    OnAssignmentChanged: func(ctx context.Context, old, new []parti.Partition) error {
        // derive added/removed by diffing old vs new if needed
        return nil
    },
}
js, _ := jetstream.New(conn)
mgr, err := parti.NewManager(&cfg, js, src, strategy.NewConsistentHash(), parti.WithHooks(hooks))
if err != nil { /* handle */ }

func WithLabelSpillGrace added in v2.9.1

func WithLabelSpillGrace(d time.Duration) Option

WithLabelSpillGrace sets how long a label's worker pool must be continuously empty before its partitions spill to unlabeled fallback workers, overriding Config.LabelSpillGrace.

Use this to reach an immediate spill (d == 0): Config.LabelSpillGrace is a non-pointer duration with a 60s default, so an explicit 0 in the config is silently re-defaulted to 60s and cannot be configured. This option preserves the difference between "unset" and "0", so WithLabelSpillGrace(0) yields a grace of 0 (spill on the first rebalance that finds the pool empty).

The option wins over Config.LabelSpillGrace, mirroring the WithWorkerLabels vs Config.WorkerLabels precedence. A negative duration causes NewManager to return an error wrapping types.ErrInvalidConfig, matching the config field's gte=0 rule.

Parameters:

  • d: Spill grace duration (>= 0; 0 means immediate spill)

Returns:

  • Option: Functional option for NewManager

func WithLogger

func WithLogger(logger Logger) Option

WithLogger sets a logger.

Parameters:

  • logger: Logger implementation (compatible with zap.SugaredLogger)

Returns:

  • Option: Functional option for NewManager

Example:

logger := zap.NewExample().Sugar()
js, _ := jetstream.New(conn)
mgr, err := parti.NewManager(&cfg, js, src, strategy.NewConsistentHash(), parti.WithLogger(logger))
if err != nil { /* handle */ }

func WithMetrics

func WithMetrics(metrics MetricsCollector) Option

WithMetrics sets a metrics collector.

A collector that additionally implements the optional AssignmentDivergenceMetricsRecorder capability also receives equal-version divergence counts; the manager type-asserts for it once at construction, so a collector without the capability loses nothing else.

Parameters:

  • metrics: MetricsCollector implementation

Returns:

  • Option: Functional option for NewManager

Example:

metrics := myPrometheusCollector
js, _ := jetstream.New(conn)
mgr, err := parti.NewManager(&cfg, js, src, strategy.NewConsistentHash(), parti.WithMetrics(metrics))
if err != nil { /* handle */ }

func WithWorkerConsumerUpdater

func WithWorkerConsumerUpdater(updater WorkerConsumerUpdater) Option

WithWorkerConsumerUpdater injects a WorkerConsumerUpdater used by Manager to apply the worker's current assignment to a single durable JetStream consumer.

Invocation Points:

  • Immediately after initial assignment (async, best-effort)
  • After each subsequent assignment change

This option enables fully manager-driven consumer reconciliation; hooks.OnAssignmentChanged can then be reserved for metrics or side effects instead of subscription wiring.

Parameters:

  • updater: Implementation that maps assignments to consumer FilterSubjects

Returns:

  • Option: Functional option for NewManager

Example:

js, _ := jetstream.New(nc)
helper, _ := durable.NewWorkerConsumer(js, durable.WorkerConsumerConfig{ /* ... */ }, handler)
mgr, err := parti.NewManager(cfg, js, src, strategy, parti.WithWorkerConsumerUpdater(helper))
if err != nil { /* handle */ }

func WithWorkerLabels added in v2.9.0

func WithWorkerLabels(labels ...string) Option

WithWorkerLabels sets this worker's label set, overriding Config.WorkerLabels. Labels are fixed for the manager's lifetime, published in every heartbeat, and drive label-based partition assignment. Invalid labels cause NewManager to return an error.

Use this instead of Config.WorkerLabels when several workers share one Config value (e.g. test clusters) but need distinct labels.

Parameters:

  • labels: Worker label set (validated, sorted, and deduplicated by NewManager)

Returns:

  • Option: Functional option for NewManager

type Partition

type Partition = types.Partition

Re-export types from the internal types package.

This file provides a stable, backward-compatible public API for the library's core types and interfaces. It uses type aliases to re-export definitions from the `types` subpackage, which contains the actual implementations.

This pattern solves the "import cycle" problem by allowing internal packages to depend on `types` without depending on the root `parti` package, while still providing a convenient `parti.State`, `parti.Logger`, etc. for users.

type PartitionSource

type PartitionSource = types.PartitionSource

Re-export interfaces from the internal types package for convenience.

type PartitionSourceUpdater

type PartitionSourceUpdater = types.PartitionSourceUpdater

Re-export interfaces from the internal types package for convenience.

type PartitionUpdater

type PartitionUpdater = types.PartitionUpdater

Re-export interfaces from the internal types package for convenience.

type State

type State = types.State

Re-export types from the internal types package.

This file provides a stable, backward-compatible public API for the library's core types and interfaces. It uses type aliases to re-export definitions from the `types` subpackage, which contains the actual implementations.

This pattern solves the "import cycle" problem by allowing internal packages to depend on `types` without depending on the root `parti` package, while still providing a convenient `parti.State`, `parti.Logger`, etc. for users.

type StreamMissingHook added in v2.5.0

type StreamMissingHook = types.StreamMissingHook

StreamMissingHook is the operator-supplied escalation invoked when the dynamic partition consumer's recovery flow detects the underlying JetStream stream is absent. See types.StreamMissingHook for the operator contract — same-durable- name preservation, compatible-config reconciliation, and the post-hook checkpoint-reset / epoch-fence semantics.

type WatchablePartitionSource

type WatchablePartitionSource = types.WatchablePartitionSource

Re-export interfaces from the internal types package for convenience.

type WorkerConsumerMetrics

type WorkerConsumerMetrics = types.WorkerConsumerMetrics

Re-export interfaces from the internal types package for convenience.

type WorkerConsumerUpdater

type WorkerConsumerUpdater interface {
	// UpdateWorkerConsumer applies the given partition assignment to the worker's durable consumer.
	//
	// See interface documentation for semantics and concurrency guarantees.
	//
	// Parameters:
	//   - ctx: Context for cancellation and deadline
	//   - workerID: Stable worker ID claimed by Manager
	//   - partitions: Complete assignment slice (may be empty for zero subjects)
	//
	// Returns:
	//   - error: Non-nil only on unrecoverable configuration or API failure after retries
	UpdateWorkerConsumer(ctx context.Context, workerID string, partitions []Partition) error
}

WorkerConsumerUpdater applies partition assignments to a worker-level durable JetStream consumer.

Semantics:

  • Single durable consumer per worker (named <ConsumerPrefix>-<workerID>)
  • Complete partition set provided each call (NOT a delta)
  • Must be idempotent: identical subject set re-applied => no change
  • SHOULD implement internal retries/backoff for transient JetStream errors
  • MUST return error only for unrecoverable misconfiguration (e.g., invalid stream)

Concurrency: Implementations SHOULD be safe for concurrent calls.

type WorkerMetrics

type WorkerMetrics = types.WorkerMetrics

Re-export interfaces from the internal types package for convenience.

Directories

Path Synopsis
cmd
partictl command
Package main is the cmd/partictl CLI for Parti environment provisioning.
Package main is the cmd/partictl CLI for Parti environment provisioning.
Package consumer provides unified JetStream consumer types for different consumption patterns.
Package consumer provides unified JetStream consumer types for different consumption patterns.
examples
basic command
Package main demonstrates basic Parti usage with default settings.
Package main demonstrates basic Parti usage with default settings.
degraded-readiness command
Package main demonstrates wiring Parti's OnDegraded hook to a k8s readiness probe.
Package main demonstrates wiring Parti's OnDegraded hook to a k8s readiness probe.
kv-watcher command
internal
assignment
Package assignment provides partition assignment calculation and distribution.
Package assignment provides partition assignment calculation and distribution.
durable
Package subscription provides durable JetStream consumer management utilities.
Package subscription provides durable JetStream consumer management utilities.
dynamicbuild
Package dynamicbuild contains pure helpers shared by the runtime dynamic-consumer code path in internal/durable and the provision SDK's dynamic-consumer alignment builder.
Package dynamicbuild contains pure helpers shared by the runtime dynamic-consumer code path in internal/durable and the provision SDK's dynamic-consumer alignment builder.
election
Package election provides leader election implementations for parti.
Package election provides leader election implementations for parti.
heartbeat
Package heartbeat provides periodic health monitoring for workers through NATS KV.
Package heartbeat provides periodic health monitoring for workers through NATS KV.
hooks
Package hooks provides hook implementations for the parti library.
Package hooks provides hook implementations for the parti library.
kvbuckets
Package kvbuckets provides shared builders for JetStream KeyValue bucket configurations used by the Parti control plane.
Package kvbuckets provides shared builders for JetStream KeyValue bucket configurations used by the Parti control plane.
logging
Package logging provides logging utilities for the Parti library.
Package logging provides logging utilities for the Parti library.
partcodec
Package partcodec implements the wire-format codec for partition tables stored in NATS JetStream KV.
Package partcodec implements the wire-format codec for partition tables stored in NATS JetStream KV.
partutil
Package partutil provides shared partition utilities used by both the public partition package and its internal implementation packages.
Package partutil provides shared partition utilities used by both the public partition package and its internal implementation packages.
ratelimit
Package ratelimit provides a minimal token-bucket rate limiter abstraction for bounding per-worker JetStream consumer-create RPC rates.
Package ratelimit provides a minimal token-bucket rate limiter abstraction for bounding per-worker JetStream consumer-create RPC rates.
retry
Package retry implements the bounded-retry envelope used to wrap the long-lived retry loops in the Parti runtime (source watcher restart, handoff watcher restart, assignment watcher, dynamic consumer recovery).
Package retry implements the bounded-retry envelope used to wrap the long-lived retry loops in the Parti runtime (source watcher restart, handoff watcher restart, assignment watcher, dynamic consumer recovery).
testutil
Package testutil provides shared test utilities and fixtures for integration tests.
Package testutil provides shared test utilities and fixtures for integration tests.
Package jsutil provides utilities for working with NATS JetStream streams and consumers.
Package jsutil provides utilities for working with NATS JetStream streams and consumers.
Package kvutil provides utilities for working with NATS JetStream KeyValue stores.
Package kvutil provides utilities for working with NATS JetStream KeyValue stores.
Package partitest provides test utilities for the Parti library.
Package partitest provides test utilities for the Parti library.
Package partition provides static partition-based NATS publishing and subscribing.
Package partition provides static partition-based NATS publishing and subscribing.
Package provision manages the NATS resources Parti's runtime depends on: control-plane KV buckets, the partition-source bucket, and the partition table stored inside the partition-source key.
Package provision manages the NATS resources Parti's runtime depends on: control-plane KV buckets, the partition-source bucket, and the partition table stored inside the partition-source key.
scripts
gap_timeline command
Package source provides built-in partition source implementations.
Package source provides built-in partition source implementations.
Package strategy provides built-in assignment strategy implementations.
Package strategy provides built-in assignment strategy implementations.
test
cmd/nats-server command
This server runs in a separate process to isolate NATS server memory overhead from parti library measurements.
This server runs in a separate process to isolate NATS server memory overhead from parti library measurements.
simulation/cmd/simulation command
label_chaos.go implements the label_heartbeat_takeover chaos primitive: see coordinator.LabelHeartbeatTakeoverEvent's doc comment for the full rationale (why this replaces a kill+respawn approach).
label_chaos.go implements the label_heartbeat_takeover chaos primitive: see coordinator.LabelHeartbeatTakeoverEvent's doc comment for the full rationale (why this replaces a kill+respawn approach).
simulation/internal/logging
Package logging provides logging utilities for the simulation.
Package logging provides logging utilities for the simulation.
simulation/internal/worker
Package worker's ChaosProofMetrics gives label chaos scenarios a race-free way to positively prove four code paths actually fired, rather than inferring it from the absence of oracle violations (which can pass vacuously if the chaos primitive silently took a different, already-tested path — e.g.
Package worker's ChaosProofMetrics gives label chaos scenarios a race-free way to positively prove four code paths actually fired, rather than inferring it from the absence of oracle violations (which can pass vacuously if the chaos primitive silently took a different, already-tested path — e.g.
Package types provides core type definitions and interfaces for the Parti library.
Package types provides core type definitions and interfaces for the Parti library.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL