consumer

package
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: 21 Imported by: 0

Documentation

Overview

Package consumer provides unified JetStream consumer types for different consumption patterns.

This package offers a consistent API for four distinct consumer types, each designed for specific use cases. All consumer types use the unified MessageHandler interface.

Choosing a Consumer Type

Type         Use Case                                Coordination  Lifecycle
[Queue]      Load-balanced workers                   None          Start → Stop
[Static]     StatefulSet fixed partition             None          Start → Stop
[Broadcast]  Fan-out to all instances                None          Start → Stop
[Dynamic]    Manager-assigned partitions (Parti)     Via Manager   Update → Stop

Pairing with Publishers

Each consumer type pairs with a publisher from partition or JetStream:

Consumer       Typical Publisher            Notes
[Queue]        js.Publish / jsutil          Plain JetStream publish; all workers share load
[Static]       partition.JSPublisher        Hashes key → fixed partition subject
[Broadcast]    js.Publish                   Single publish reaches all instances
[Dynamic]      partition.JSPublisher        Hashes key → partition assigned by Manager

Example: publish to a Static or Dynamic consumer using partition.JSPublisher:

pub, err := partition.NewJSPublisher(js, partition.NewConfig(
    partition.WithNumPartitions(10),
    partition.WithSubjectPattern("events.{{partition}}"),
))
if err != nil { log.Fatal(err) }

// Key "order-123" is consistently hashed to the same partition.
if err := pub.Publish(ctx, "order-123", payload); err != nil { log.Fatal(err) }

Lifecycle

Each consumer type follows a consistent lifecycle pattern:

Queue and Static (Start/Stop pattern)

// Create and configure
c, err := consumer.NewQueue(js, "stream", "consumer", "subject.>", handler)
if err != nil { log.Fatal(err) }

// Always stop on exit to avoid goroutine leaks
defer c.Stop(ctx)

// Begin consuming (starts background goroutine)
if err := c.Start(ctx); err != nil { log.Fatal(err) }

// ... application runs ...

Broadcast (Start/Stop pattern)

c, err := consumer.NewBroadcast(js, "stream", "prefix", "events.>", handler)
if err != nil { log.Fatal(err) }
defer c.Stop(ctx)

if err := c.Start(ctx); err != nil { log.Fatal(err) }

Dynamic (Update/Stop pattern)

c, err := consumer.NewDynamic(js, "stream", "prefix", "orders.{{.PartitionID}}", handler)
if err != nil { log.Fatal(err) }
defer c.Stop(ctx)

// Consumption starts when Update is called (typically by Parti Manager)
if err := c.Update(ctx, "worker-0", partitions); err != nil { log.Fatal(err) }

Thread Safety

All consumer types are safe for concurrent use. Lifecycle methods (Start, Stop, Update) are serialized internally to prevent race conditions.

Message Handler

All consumer types use the unified MessageHandler interface:

handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
    // Process message
    return nil  // auto-ack on success
})

By default, returning nil automatically acknowledges the message, and returning an error triggers a negative acknowledgement (NAK). Set ManualAck=true to control acknowledgement explicitly via msg.Ack(), msg.Nak(), or msg.Term().

Error Handling

Constructor errors are validation failures (nil JetStream, missing required fields). These are not exported sentinel errors—use string matching or error wrapping checks.

Runtime errors from lifecycle methods:

  • context.DeadlineExceeded: Stop timed out waiting for graceful shutdown
  • Worker ID changed unexpectedly (Dynamic consumer only)
  • Partition count exceeds configured limit (Dynamic consumer only)

Consumer Types

Queue

Load-balanced shared consumer. Use for classic worker queue patterns where each message goes to exactly one instance. Multiple replicas share a single durable consumer name, achieving queue group semantics.

c, _ := consumer.NewQueue(js, "jobs", "job-workers", "jobs.>", handler)
defer c.Stop(ctx)
_ = c.Start(ctx)

Static

Fixed partition assignment (e.g., StatefulSet ordinal). Use when each pod processes exactly one predetermined partition.

c, _ := consumer.NewStatic(js, "events", "processor-0", "events.{{partition}}", 10, 0, handler)
defer c.Stop(ctx)
_ = c.Start(ctx)

Dynamic

Manager-assigned partitions (Parti core). Use when partitions are dynamically distributed by a coordination layer. The consumer manages multiple internal per-partition consumers based on assignments.

c, _ := consumer.NewDynamic(js, "events", "worker", "orders.{{.PartitionID}}.events", handler)
defer c.Stop(ctx)
_ = c.Update(ctx, workerID, partitions)

Broadcast

Fan-out to all instances. Use when every instance must receive every message (caching, audit logs, notifications). Each instance receives a copy of every message matching the wildcard filter.

IMPORTANT: The stream MUST use LimitsPolicy or InterestPolicy. WorkQueuePolicy is incompatible because it delivers each message to exactly one consumer.

c, _ := consumer.NewBroadcast(js, "events", "cache-updater", "events.>", handler)
defer c.Stop(ctx)
_ = c.Start(ctx)

Auto-Recovery

All consumer types can automatically recreate their durable JetStream consumer when it is unexpectedly deleted (e.g., after an InactiveThreshold expiry or server-side administrative deletion). Use WithRecoveryStrategy to enable it:

c, err := consumer.NewQueue(js, "stream", "workers", "jobs.>", handler,
    consumer.WithRecoveryStrategy(consumer.RecoverFromNew),
)

Choose a strategy based on your delivery guarantee requirements:

Strategy                    Behavior on recreation          Risk
[RecoveryDisabled]          No recreation (default)         None
[RecoverFromNew]            Skip missed messages            Message loss during outage
[RecoverFromLastProcessed]  Resume from last acked msg      Works with any ManualAck
[RecoverFromBeginning]      Full stream replay from start   Replay storm on large streams

Per-consumer support:

Consumer     RecoveryDisabled  RecoverFromNew  RecoverFromLastProcessed  RecoverFromBeginning
[Queue]      ✓                 ✓               ✗ (shared durable)        ✓
[Broadcast]  ✓                 ✓               ✓ (any ManualAck)         ✓
[Static]     ✓                 ✓               ✓ (any ManualAck)         ✓
[Dynamic]    ✓                 ✓               ✓ (any ManualAck)         ✓

The only invalid combination is RecoverFromLastProcessed on a Queue, which returns ErrInvalidConfig at construction time.

Options

All constructors accept functional options for customization:

c, _ := consumer.NewQueue(js, "stream", "consumer", "subject.>", handler,
    consumer.WithLogger(myLogger),
    consumer.WithAckWait(60*time.Second),
    consumer.WithBatchSize(10),
)

Common options (Option) work with all consumer types. Type-specific options (e.g., QueueOption, StaticOption) are enforced at compile time.

Field Mapping

The consumer package uses unified field names in constructors:

consumerName/Prefix  ConsumerName    ConsumerPrefix   ConsumerPrefix   ConsumerName
subject pattern      SubjectPattern  SubjectTemplate  FilterSubject    FilterSubject

Helpers

The package provides helper functions to assist with partition determination in Kubernetes environments:

Example (BroadcastConsumer)

This example demonstrates how to create a Broadcast consumer where every instance receives every message (fan-out pattern).

package main

import (
	"context"
	"fmt"

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

func main() {
	var js jetstream.JetStream

	handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		fmt.Printf("Cache invalidation: %s\n", string(msg.Data()))
		return nil
	})

	c, err := consumer.NewBroadcast(
		js,
		"CACHE-EVENTS",  // streamName
		"cache-updater", // consumerPrefix
		"cache.>",       // filterSubject (wildcard for all cache events)
		handler,
		consumer.WithInstanceID("pod-abc123"), // unique per instance
	)
	if err != nil {
		fmt.Printf("Failed to create broadcast consumer: %v\n", err)
		return
	}

	ctx := context.Background()

	if err := c.Start(ctx); err != nil {
		fmt.Printf("Failed to start: %v\n", err)
		return
	}

	// Every instance running this code receives all cache.> messages
	// Cleanup
	_ = c.Stop(ctx)

}
Output:
Failed to create broadcast consumer: invalid consumer configuration: JetStream context is required
Example (DynamicConsumer)

This example demonstrates how to create a Dynamic consumer that receives partition assignments from the Parti Manager.

package main

import (
	"context"
	"fmt"

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

func main() {
	var js jetstream.JetStream

	handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		fmt.Printf("Processing order event: %s\n", string(msg.Data()))
		return nil
	})

	c, err := consumer.NewDynamic(
		js,
		"ORDERS",                  // streamName
		"worker",                  // consumerPrefix
		"orders.{{.PartitionID}}", // subjectTemplate (Go template syntax)
		handler,
	)
	if err != nil {
		fmt.Printf("Failed to create dynamic consumer: %v\n", err)
		return
	}

	ctx := context.Background()

	// In a real application, the Parti Manager calls Update automatically.
	// Here we demonstrate manual usage:
	partitions := []types.Partition{
		{Keys: []string{"partition-0"}},
		{Keys: []string{"partition-1"}},
	}

	if err := c.Update(ctx, "worker-0", partitions); err != nil {
		fmt.Printf("Failed to update: %v\n", err)
		return
	}

	// Consumer is now processing messages for partition-0 and partition-1
	// Cleanup
	_ = c.Stop(ctx)

}
Output:
Failed to create dynamic consumer: invalid consumer configuration: JetStream context is required
Example (MessageHandler)

This example demonstrates how to use the MessageHandlerFunc adapter to convert a function into a MessageHandler.

package main

import (
	"context"
	"fmt"

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

func main() {
	// Simple handler that processes and auto-acks
	simple := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		data := msg.Data()
		// Process data...
		_ = data
		return nil // nil = auto-ack
	})

	// Handler that explicitly signals failure (triggers NAK)
	withError := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		if err := processMessage(msg); err != nil {
			return err // non-nil = auto-NAK
		}
		return nil
	})

	// Handler for manual ack mode (requires WithManualAck option)
	manual := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		if err := processMessage(msg); err != nil {
			_ = msg.Term() // terminal failure, don't redeliver
			return err
		}
		_ = msg.Ack() // explicit ack
		return nil
	})

	// All handlers implement MessageHandler interface
	fmt.Printf("Created %d handlers\n", 3)

	// Use any handler with a consumer constructor
	_ = simple
	_ = withError
	_ = manual
}

func processMessage(_ jetstream.Msg) error {

	return nil
}
Output:
Created 3 handlers
Example (QueueConsumer)

This example demonstrates how to create a Queue consumer for load-balanced message processing across multiple worker instances.

In production, you would obtain js from nats.Connect + jetstream.New.

package main

import (
	"context"
	"fmt"
	"time"

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

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

	handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		fmt.Printf("Processing job: %s\n", string(msg.Data()))
		return nil // auto-ack on success
	})

	q, err := consumer.NewQueue(
		js,
		"JOBS",          // streamName
		"job-processor", // consumerName (shared across replicas)
		"jobs.>",        // filterSubject
		handler,
		consumer.WithAckWait(30*time.Second),
		consumer.WithBatchSize(10),
	)
	if err != nil {
		fmt.Printf("Failed to create queue: %v\n", err)
		return
	}

	ctx := context.Background()

	if err := q.Start(ctx); err != nil {
		fmt.Printf("Failed to start: %v\n", err)
		return
	}

	// Consumer is now processing messages in the background.
	// In a real application, wait for shutdown signal, then:
	_ = q.Stop(ctx)

}
Output:
Failed to create queue: invalid consumer configuration: JetStream context is required
Example (QueueConsumerWithRecovery)

This example demonstrates enabling auto-recovery on a Queue consumer. When the durable consumer is unexpectedly deleted (e.g., server restart, InactiveThreshold expiry), the consumer automatically recreates it and resumes processing new messages — no restart required.

RecoverFromNew is the safest choice for Queue consumers: messages published during the outage are skipped, but there is no replay storm risk.

package main

import (
	"context"
	"fmt"

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

func main() {
	var js jetstream.JetStream

	handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		fmt.Printf("Processing job: %s\n", string(msg.Data()))
		return nil
	})

	q, err := consumer.NewQueue(
		js,
		"JOBS",
		"job-processor",
		"jobs.>",
		handler,
		consumer.WithRecoveryStrategy(consumer.RecoverFromNew),
	)
	if err != nil {
		fmt.Printf("Failed to create queue: %v\n", err)
		return
	}

	ctx := context.Background()
	if err := q.Start(ctx); err != nil {
		fmt.Printf("Failed to start: %v\n", err)
		return
	}
	_ = q.Stop(ctx)

}
Output:
Failed to create queue: invalid consumer configuration: JetStream context is required
Example (RecoveryStrategyValidation)

This example shows that RecoverFromLastProcessed on a Queue consumer returns ErrInvalidConfig at construction time. Queue shares one durable across replicas, making per-process checkpoint tracking nondeterministic.

package main

import (
	"fmt"

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

func main() {
	cfg := consumer.QueueConfig{
		StreamName:       "JOBS",
		ConsumerName:     "job-workers",
		FilterSubject:    "jobs.>",
		RecoveryStrategy: consumer.RecoverFromLastProcessed,
	}

	if err := cfg.Validate(); err != nil {
		fmt.Printf("config error: %v\n", err)
	}

}
Output:
config error: invalid consumer configuration: RecoverFromLastProcessed is not supported for Queue consumers (per-process checkpoint tracking is unsafe when multiple instances share one durable)
Example (StaticConsumer)

This example demonstrates how to create a Static consumer for StatefulSet deployments where each pod handles a fixed partition.

package main

import (
	"context"
	"fmt"

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

func main() {
	var js jetstream.JetStream

	handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		fmt.Printf("Processing event: %s\n", string(msg.Data()))
		return nil
	})

	// Pod ordinal determines partition (e.g., pod-0 → partition 0)
	podOrdinal := 0
	numPartitions := 10

	c, err := consumer.NewStatic(
		js,
		"EVENTS",               // streamName
		"processor-0",          // consumerName (unique per partition)
		"events.{{partition}}", // subjectPattern
		numPartitions,          // total partitions
		podOrdinal,             // this pod's partition
		handler,
	)
	if err != nil {
		fmt.Printf("Failed to create static consumer: %v\n", err)
		return
	}

	ctx := context.Background()

	if err := c.Start(ctx); err != nil {
		fmt.Printf("Failed to start: %v\n", err)
		return
	}

	// This consumer only receives messages for partition 0
	fmt.Printf("Consuming partition %d, subject: %s\n", c.Partition(), c.Subject())

	// Cleanup
	_ = c.Stop(ctx)

}
Output:
Failed to create static consumer: invalid consumer configuration: JetStream context is required
Example (StaticConsumerWithLastProcessedRecovery)

This example demonstrates RecoverFromLastProcessed on a Static consumer with the default ManualAck=false. After recovery, the consumer resumes from the message immediately after the last auto-acknowledged one — providing at-least-once delivery without a full stream replay.

package main

import (
	"context"
	"fmt"

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

func main() {
	var js jetstream.JetStream

	handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		fmt.Printf("Processing event: %s\n", string(msg.Data()))
		return nil // auto-ack: advances the recovery checkpoint
	})

	c, err := consumer.NewStatic(
		js,
		"EVENTS",
		"processor-0",
		"events.{{partition}}",
		10, // numPartitions
		0,  // partitionIndex
		handler,
		consumer.WithRecoveryStrategy(consumer.RecoverFromLastProcessed),
	)
	if err != nil {
		fmt.Printf("Failed to create static consumer: %v\n", err)
		return
	}

	ctx := context.Background()
	if err := c.Start(ctx); err != nil {
		fmt.Printf("Failed to start: %v\n", err)
		return
	}
	_ = c.Stop(ctx)

}
Output:
Failed to create static consumer: invalid consumer configuration: JetStream context is required
Example (StaticConsumerWithLastProcessedRecovery_ManualAck)

This example demonstrates RecoverFromLastProcessed with ManualAck=true. The framework intercepts msg.Ack() to advance the checkpoint, so the handler retains explicit control over when acknowledgement happens.

package main

import (
	"context"
	"fmt"

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

func main() {
	var js jetstream.JetStream

	handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		fmt.Printf("Processing event: %s\n", string(msg.Data()))
		// Calling msg.Ack() advances the recovery checkpoint automatically.
		return msg.Ack()
	})

	c, err := consumer.NewStatic(
		js,
		"EVENTS",
		"processor-0",
		"events.{{partition}}",
		10, // numPartitions
		0,  // partitionIndex
		handler,
		consumer.WithManualAck(true),
		consumer.WithRecoveryStrategy(consumer.RecoverFromLastProcessed),
	)
	if err != nil {
		fmt.Printf("Failed to create static consumer: %v\n", err)
		return
	}

	ctx := context.Background()
	if err := c.Start(ctx); err != nil {
		fmt.Printf("Failed to start: %v\n", err)
		return
	}
	_ = c.Stop(ctx)

}
Output:
Failed to create static consumer: invalid consumer configuration: JetStream context is required
Example (WipHandler)

This example demonstrates how to wrap a message handler with automatic heartbeats for long-running processing using WIPHandler.

WIPHandler periodically calls msg.InProgress() to extend the AckWait deadline, preventing JetStream from redelivering messages while processing is still active.

package main

import (
	"context"
	"fmt"
	"time"

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

func main() {
	// Base handler that performs long-running processing
	slowHandler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		// Simulate long-running work (e.g., ML inference, large file processing)
		// This could take 30+ seconds
		fmt.Println("Starting long-running processing...")

		// Normally this would timeout and redeliver, but WIPHandler
		// sends InProgress() every 10 seconds to keep it alive
		// time.Sleep(45 * time.Second)

		fmt.Println("Processing complete")

		return nil
	})

	// Wrap with WIPHandler for automatic heartbeats
	// Interval should be < AckWait/2 (e.g., AckWait=30s, Interval=10s)
	wrappedHandler := consumer.NewWIPHandler(slowHandler, consumer.WIPConfig{
		Interval: 10 * time.Second,
		// Logger: myLogger, // Optional: log heartbeat errors
	})

	// Use with any consumer type
	var js jetstream.JetStream
	_, err := consumer.NewQueue(
		js,
		"LONG-JOBS",
		"slow-processor",
		"jobs.slow.>",
		wrappedHandler,
		consumer.WithAckWait(30*time.Second), // Heartbeat at 10s < 30s/2
	)
	if err != nil {
		fmt.Printf("Failed to create queue: %v\n", err)
		return
	}

}
Output:
Failed to create queue: invalid consumer configuration: JetStream context is required
Example (WipHandlerWithDynamic)

This example demonstrates using WIPHandler with Dynamic consumer for partition-aware long-running processing.

package main

import (
	"context"
	"fmt"
	"time"

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

func main() {
	// Handler that processes large datasets per partition
	handler := consumer.MessageHandlerFunc(func(ctx context.Context, msg jetstream.Msg) error {
		fmt.Printf("Processing batch for subject: %s\n", msg.Subject())
		// Long-running batch processing...
		return nil
	})

	// Wrap with heartbeats
	wrapped := consumer.NewWIPHandler(handler, consumer.WIPConfig{
		Interval: 15 * time.Second, // AckWait is 60s, so 15s is safe (< 30s)
	})

	var js jetstream.JetStream
	_, err := consumer.NewDynamic(
		js,
		"BATCH-EVENTS",
		"batch-worker",
		"batch.{{.PartitionID}}.events",
		wrapped,
		consumer.WithAckWait(60*time.Second),
	)
	if err != nil {
		fmt.Printf("Failed to create dynamic consumer: %v\n", err)
		return
	}

}
Output:
Failed to create dynamic consumer: invalid consumer configuration: JetStream context is required

Index

Examples

Constants

View Source
const (
	// DefaultAckWait is the default time allowed for processing a message
	// before it is considered lost and re-delivered.
	DefaultAckWait = 30 * time.Second

	// DefaultMaxDeliver is the default maximum delivery attempts.
	// -1 means unlimited re-delivery.
	DefaultMaxDeliver = -1

	// DefaultInactiveThreshold is the default duration after which an idle
	// JetStream consumer is automatically deleted by the server.
	DefaultInactiveThreshold = 24 * time.Hour

	// DefaultFetchBatchSize is the default number of messages to pull from
	// the server in a single fetch request.
	DefaultFetchBatchSize = 1

	// DefaultFetchMaxWait is the default maximum duration to wait for a
	// batch of messages during a fetch.
	DefaultFetchMaxWait = 5 * time.Second

	// DefaultMaxAckPending is the default number of messages that can be
	// in-flight (unacknowledged) per consumer. Set to 1 for strict ordered
	// processing. Increase for higher throughput at the cost of ordering.
	DefaultMaxAckPending = 1

	// DefaultMaxWaiting is the default maximum number of outstanding pull
	// requests. Set conservatively to 2 to prevent thundering-herd issues
	// when managing many partitions per worker.
	DefaultMaxWaiting = 2
)

Default configuration values for consumer types.

These constants cover the most tuning-relevant settings. Internal retry, escalation, and health-check defaults remain unexported in the implementation.

View Source
const (
	// RecoveryDisabled is the zero value. No strategy-aware consumer recreation is
	// performed on deletion. The consumer retries transient errors with backoff but
	// does not recreate the durable with an adjusted DeliverPolicy.
	RecoveryDisabled = recovery.Disabled

	// RecoverFromNew recreates the consumer to deliver only newly published messages.
	// Messages that arrived while the consumer was absent are skipped entirely.
	//
	// Pros: zero replay risk; safe for Queue consumers and any consumer where
	// missing a window of messages is acceptable.
	// Cons: messages published between deletion and recreation are lost.
	//
	// # WorkQueuePolicy streams
	//
	// Not compatible with WorkQueuePolicy streams. NATS only permits
	// [jetstream.DeliverAllPolicy] on work-queue streams; this strategy maps to
	// [jetstream.DeliverNewPolicy], which NATS rejects. [Queue.Start],
	// [Static.Start], and [Dynamic.Update] return [ErrInvalidConfig] when this
	// combination is detected. Use [RecoverFromBeginning] or [RecoveryDisabled]
	// on WorkQueuePolicy streams.
	RecoverFromNew = recovery.FromNew

	// RecoverFromLastProcessed recreates the consumer starting at the sequence
	// immediately after the last acknowledged message. This provides at-least-once
	// delivery without a full replay storm.
	//
	// Works with both ManualAck modes:
	//   - ManualAck=false (default): checkpoint advances automatically after each
	//     successful handler return (auto-ack path).
	//   - ManualAck=true: the message passed to the handler intercepts msg.Ack()
	//     and msg.DoubleAck(); the checkpoint advances when the handler calls either.
	//     Calling msg.Nak(), msg.Term(), or msg.NakWithDelay() does not advance it.
	//
	// Not supported by [Queue] consumers (shared durable makes per-process
	// checkpointing nondeterministic across replicas).
	//
	// # WorkQueuePolicy streams
	//
	// Not compatible with WorkQueuePolicy streams. This strategy maps to
	// [jetstream.DeliverByStartSequencePolicy], which NATS rejects on work-queue
	// streams. [Static.Start] and [Dynamic.Update] return [ErrInvalidConfig]
	// when this combination is detected.
	RecoverFromLastProcessed = recovery.FromLastProcessed

	// RecoverFromBeginning recreates the consumer to replay all messages from
	// the beginning of the stream.
	//
	// WARNING: causes a full backlog replay. Use only for small or bounded streams
	// where complete reprocessing is intentional and safe.
	//
	// This is the correct recovery strategy for WorkQueuePolicy streams because
	// it maps to [jetstream.DeliverAllPolicy] — the only DeliverPolicy NATS
	// accepts on work-queue streams. On WorkQueuePolicy, acknowledged messages
	// are deleted immediately, so in practice the "full replay" covers only the
	// unacknowledged backlog at the time of consumer deletion.
	RecoverFromBeginning = recovery.FromBeginning
)

Recovery strategy constants control how a consumer resumes after its underlying durable is unexpectedly deleted. The zero value RecoveryDisabled is safe and preserves the pre-existing behavior (backoff-retry only, no durable recreation).

Note: Dynamic consumers have a separate sliding-window iterator-failure detector that attempts consumer rebind independently of this setting. That mechanism is always active and is not affected by the recovery strategy.

View Source
const DefaultWIPMinInterval = 100 * time.Millisecond

DefaultWIPMinInterval is the minimum allowed heartbeat interval. Intervals below this threshold are clamped to prevent excessive overhead.

Variables

View Source
var (
	// ErrWorkerIDMutation is returned by [Dynamic.Update] when the workerID
	// changes and [DynamicConfig.AllowWorkerIDChange] is false.
	ErrWorkerIDMutation = durable.ErrWorkerIDMutation

	// ErrMaxSubjectsExceeded is returned by [Dynamic.Update] when the
	// partition count exceeds MaxConcurrentSubjects.
	ErrMaxSubjectsExceeded = durable.ErrMaxSubjectsExceeded

	// ErrConsumerStopped is returned by [Static.Start], [Broadcast.Start],
	// [Broadcast.UpdateWorkerConsumer], [Dynamic.Update], and
	// [Dynamic.UpdateWorkerConsumer] when the consumer has already been stopped
	// or closed. Stop is terminal for Static, Broadcast, and Dynamic consumers:
	// create a new instance to consume again.
	ErrConsumerStopped = types.ErrConsumerStopped
)

Re-exported sentinel errors from internal packages. Use errors.Is to check for these errors in Dynamic.Update return values.

IMPORTANT: These variables must not be reassigned. Treat them as read-only constants. Reassigning any sentinel will break all errors.Is() checks.

View Source
var ErrInvalidConfig = errors.New("invalid consumer configuration")

ErrInvalidConfig is returned by constructors (NewQueue, NewStatic, NewDynamic, NewBroadcast) when a required parameter is nil, empty, or out of range.

Use errors.Is to check for this error programmatically:

q, err := consumer.NewQueue(js, "", "", "", handler)
if errors.Is(err, consumer.ErrInvalidConfig) {
    // handle validation failure
}

Functions

func CheckWorkQueueRecoveryCompat added in v2.4.1

func CheckWorkQueueRecoveryCompat(ctx context.Context, js jetstream.JetStream, streamName string, strategy RecoveryStrategy) error

CheckWorkQueueRecoveryCompat returns ErrInvalidConfig when the stream uses WorkQueuePolicy and the recovery strategy requires a non-DeliverAllPolicy. NATS only permits DeliverAllPolicy on work-queue streams; RecoverFromNew (DeliverNewPolicy) and RecoverFromLastProcessed (DeliverByStartSequencePolicy) would silently fail during every recovery attempt.

The check is best-effort: failures to fetch stream info are silently ignored so callers are not blocked by transient connectivity issues. This means transient JetStream API failures during the pre-flight do not block consumer updates; the runtime continues as if the check passed.

For Dynamic, the per-consumer outcome is cached: a pass recorded during a transient fetch failure is not re-evaluated until a stream-recreate resets the check, so a genuinely incompatible configuration may go undetected until recovery first misbehaves.

This function is exported so the provision SDK can reuse the exact same implementation in its dynamic-consumer alignment check (see provision.ValidateLiveDynamicConsumers). Reusing rather than duplicating guarantees byte-equivalent error semantics with the runtime Dynamic.Update path.

func GetPartitionFromEnv

func GetPartitionFromEnv() (int, error)

GetPartitionFromEnv reads the partition index from environment. Wraps partition.GetPartitionFromEnv.

func ParseStatefulSetOrdinal

func ParseStatefulSetOrdinal(hostname string) (int, error)

ParseStatefulSetOrdinal extracts the ordinal index from a StatefulSet pod hostname.

Wraps partition.ParseStatefulSetOrdinal.

func WithIteratorFactory

func WithIteratorFactory(f func(cons jetstream.Consumer, batch int, expiry time.Duration) (jetstream.MessagesContext, error)) interface {
	QueueOption
	BroadcastOption
	DynamicOption
}

WithIteratorFactory sets a custom iterator factory (for testing). Supported by: Queue, Broadcast, Dynamic.

func WithRetry

func WithRetry(cfg RetryConfig) interface {
	QueueOption
	BroadcastOption
	DynamicOption
}

WithRetry sets the retry backoff configuration.

Supported by: Queue, Broadcast, Dynamic. Static consumers use a fixed internal retry and ignore this option.

Types

type Broadcast

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

Broadcast is a fan-out consumer where every instance receives every message. Uses a unique durable name per instance.

Lifecycle

Create with NewBroadcast, then call Broadcast.Start to begin consuming. Clean up with Broadcast.Stop:

consumer, err := consumer.NewBroadcast(js, "stream", "cache-updater", "events.>", handler)
if err != nil { log.Fatal(err) }
defer consumer.Stop(ctx)

if err := consumer.Start(ctx); err != nil { log.Fatal(err) }

Stream Requirement

The stream MUST use LimitsPolicy or InterestPolicy. WorkQueuePolicy is incompatible because it delivers each message to exactly one consumer, defeating the fan-out purpose.

Thread Safety

Broadcast is safe for concurrent use. Broadcast.Start and Broadcast.Stop are serialized internally.

func NewBroadcast

func NewBroadcast(
	js jetstream.JetStream,
	streamName, consumerPrefix, filterSubject string,
	handler MessageHandler,
	opts ...BroadcastOption,
) (*Broadcast, error)

NewBroadcast creates a new broadcast fan-out consumer.

func (*Broadcast) Start

func (b *Broadcast) Start(ctx context.Context) error

Start begins consuming messages.

The consumer creates a durable JetStream consumer with a unique name derived from the InstanceID and starts a pull loop. All messages matching the FilterSubject are delivered to the handler.

Start may only be called once. Calling Start on an already-started consumer is a no-op.

Stop is terminal: after Broadcast.Stop is called, Start returns ErrConsumerStopped. Create a new instance to consume again.

Parameters:

  • ctx: Context for the start operation. Used for JetStream API calls.

Returns:

  • error: Non-nil if JetStream consumer creation fails, or ErrConsumerStopped if the consumer has been stopped.

func (*Broadcast) Stop

func (b *Broadcast) Stop(ctx context.Context) error

Stop gracefully stops the consumer.

Stop cancels the internal pull loop and waits for pending message processing to complete (up to the context deadline). The underlying JetStream consumer is NOT deleted; it will be garbage-collected by the server after InactiveThreshold.

Stop is terminal: after Stop returns, any subsequent call to Broadcast.Start or Broadcast.UpdateWorkerConsumer returns ErrConsumerStopped. Create a new instance to consume again.

If the consumer is stopped while still registered with a running manager, the manager will retry the failed update indefinitely (by design). Stop the manager first, or deregister this consumer before calling Stop.

Stop is idempotent; calling it multiple times is safe.

Parameters:

  • ctx: Context with shutdown deadline. If the deadline expires, Stop returns context.DeadlineExceeded but the consumer will still eventually stop.

Returns:

  • error: Context error if the wait times out; nil otherwise.

func (*Broadcast) UpdateWorkerConsumer

func (b *Broadcast) UpdateWorkerConsumer(ctx context.Context, workerID string, partitions []types.Partition) error

UpdateWorkerConsumer implements the WorkerConsumerUpdater interface.

For Broadcast consumers, this is equivalent to Broadcast.Start. The workerID and partitions arguments are ignored because Broadcast receives all messages matching the filter regardless of partition assignment.

This method exists for compatibility with code that uses WorkerConsumerUpdater interface.

type BroadcastConfig

type BroadcastConfig struct {
	CommonConfig

	// StreamName is the JetStream stream to consume from.
	// Required.
	StreamName string `validate:"required"`

	// InstanceID identifies the unique instance of the application.
	//
	// This ID allows each instance to have its own durable consumer, ensuring
	// that every instance receives a copy of every message (fan-out).
	//
	// Accepted formats:
	//   - "fixed-string": Uses the literal value as the identity
	//   - "env:ENV_NAME": Uses the value of the specified environment variable
	//
	// If empty, the consumer will attempt to derive an identity from
	// HOSTNAME, then POD_NAME, and finally fall back to a generated short ID.
	InstanceID string

	// FilterSubject is the subject filter to consume from.
	// Supports wildcards (e.g., "orders.*", "events.>").
	// Messages matching this filter will be broadcast to all instances.
	FilterSubject string `validate:"required"`

	// ConsumerPrefix is the prefix for the durable consumer name.
	//
	// The final durable name is constructed as "<ConsumerPrefix>_broadcast_<InstanceID>".
	// This ensures that each instance gets a unique durable name, achieving fan-out.
	ConsumerPrefix string `validate:"required"`

	// Retry configures the backoff behavior for the consume loop.
	//
	// On each iterator failure the loop sleeps for a jittered delay that grows via
	// decorrelated jitter from Base toward Max using the Multiplier factor. Seed
	// makes the sequence deterministic (useful in tests); zero uses the package-level
	// PRNG. The delay resets to zero after every successful iteration cycle.
	Retry RetryConfig

	// IteratorFactory optionally overrides the internal iterator creation logic.
	// This is primarily used for testing to inject mock iterators.
	IteratorFactory func(cons jetstream.Consumer, batch int, expiry time.Duration) (jetstream.MessagesContext, error)

	// RecoveryStrategy defines how a recreated consumer resumes after an unexpected deletion.
	//
	// All strategies are supported. [RecoverFromLastProcessed] works with both
	// ManualAck=false (checkpoint advances automatically) and ManualAck=true
	// (checkpoint advances when the handler calls msg.Ack() or msg.DoubleAck()).
	RecoveryStrategy RecoveryStrategy
}

BroadcastConfig configures a Broadcast consumer.

func (*BroadcastConfig) SetDefaults

func (c *BroadcastConfig) SetDefaults() error

SetDefaults applies default values to the configuration.

func (*BroadcastConfig) Validate

func (c *BroadcastConfig) Validate() error

Validate checks configuration constraints.

type BroadcastOption

type BroadcastOption interface {
	// contains filtered or unexported methods
}

BroadcastOption applies only to Broadcast consumers.

This interface allows both universal Options and Broadcast-specific options (like WithInstanceID) to be passed to NewBroadcast.

func WithInstanceID

func WithInstanceID(id string) BroadcastOption

WithInstanceID sets the instance ID for broadcast consumers. If unset, hostname or env var is used.

type CommonConfig

type CommonConfig struct {
	// Logger provides structured logging for the consumer.
	// If nil, a no-op logger is used.
	Logger types.Logger

	// Metrics is the metrics collector for worker consumer operations.
	// If nil, a no-op collector is used.
	Metrics types.WorkerConsumerMetrics

	// ManualAck disables automatic acknowledgement of messages.
	//
	// When false (default):
	//  - If the handler returns nil, the message is automatically acknowledged.
	//  - If the handler returns an error, the message is negatively acknowledged (Nak).
	//
	// When true:
	//  - The handler MUST explicitly call msg.Ack(), msg.Nak(), or msg.Term().
	//  - A returned handler error is discarded (neither logged nor acted on);
	//    surface failures from inside the handler itself.
	ManualAck bool

	// AckWait is the time allowed for processing a message before it is considered lost
	// and re-delivered by the server.
	//
	// This should be longer than the expected maximum processing time of a single message.
	//
	// Default: 30s.
	AckWait time.Duration `default:"30s" validate:"gt=0"`

	// MaxDeliver is the maximum number of times a message will be delivered.
	//
	// If a message fails processing (Nak) or times out (AckWait) this many times,
	// it will be terminated or moved to a Dead Letter Queue (if configured on the stream).
	//
	// Default: -1 (unlimited).
	MaxDeliver int `default:"-1" validate:"gte=-1"`

	// BatchSize is the maximum number of messages to pull from the server in a single request.
	//
	// A higher batch size can improve throughput but typically increases memory usage
	// and potentially latency for individual messages if processing is slow.
	//
	// Default: 1.
	BatchSize int `default:"1" validate:"gt=0"`

	// FetchTimeout is the maximum duration to wait for a batch of messages to arrive
	// when pulling from the server.
	//
	// If no messages are available within this timeout, the pull request expires.
	// The consumer loop manages this automatically.
	//
	// Minimum: 1s. NATS rejects a PullExpiry below 1s at iterator-creation
	// time; a sub-second value previously caused Start to return success and
	// then fail every iterator creation forever — a permanently dead consumer
	// with no terminal signal. Construction now fails fast instead.
	//
	// Default: 5s.
	FetchTimeout time.Duration `default:"5s" validate:"gt=0"`

	// PullHeartbeatCap optionally bounds the derived nats.go PullHeartbeat
	// value used by the consumer's pull loop. The heartbeat is normally
	// FetchTimeout/2, clamped to nats.go's PullHeartbeat validity range
	// [500ms, 30s]; when PullHeartbeatCap > 0 the derived heartbeat is
	// further capped to this value.
	//
	// The heartbeat drives missed-heartbeat detection: nats.go's
	// ErrNoHeartbeat fires at roughly 2x the heartbeat, which is how a
	// deleted durable consumer is detected. Raising FetchTimeout to reduce
	// idle pull-request churn also raises the derived heartbeat and
	// therefore that detection latency; this cap bounds it independent of
	// FetchTimeout.
	//
	// 0 (default) disables the cap: the heartbeat stays FetchTimeout/2,
	// capped only at 30s. A nonzero value outside [500ms, 30s] (nats.go's
	// PullHeartbeat bounds) is rejected at construction with
	// ErrInvalidConfig. See [WithPullHeartbeatCap].
	PullHeartbeatCap time.Duration `validate:"gte=0"`

	// MaxWaiting is the maximum number of outstanding pull requests allowed.
	//
	// This controls the pre-fetch buffer. A value of 2 with BatchSize of 1 means
	// there can be 2 messages buffered locally (1 being processed, 1 ready).
	//
	// Default: 2.
	MaxWaiting int `default:"2" validate:"gt=0"`

	// MaxAckPending limits the number of messages that can be in-flight (unacknowledged)
	// at any given time.
	//
	// If the limit is reached, the server will pause delivery until some messages are acknowledged.
	// If zero, the server's consumer default is used.
	MaxAckPending int `validate:"gte=0"`

	// InactiveThreshold is the duration after which an idle consumer (with no active subscriptions)
	// will be automatically deleted by the server.
	//
	// For durable consumers, this should be set high enough to survive application restarts.
	//
	// Default: 24h.
	InactiveThreshold time.Duration `default:"24h" validate:"gt=0"`

	// AckPolicy controls the JetStream acknowledgement policy.
	//
	// Typically set to AckExplicitPolicy for reliable processing.
	// Defaults to AckExplicitPolicy if usually not set manually.
	AckPolicy jetstream.AckPolicy

	// ConsumerMemoryStorage, when true, sets the underlying
	// jetstream.ConsumerConfig.MemoryStorage flag on consumer create,
	// keeping the consumer's delivery/ack state in memory rather than
	// inheriting the stream's storage type.
	//
	// Default: false (inherit stream storage).
	//
	// Trade-off: the consumer's delivery/ack offsets are NOT durable
	// across coordinated cluster restart. With ConsumerReplicas ≥ 2,
	// single-node failure is still survivable via raft peers. With
	// ConsumerReplicas = 1, any failure of the consumer-state holder
	// loses ack state and triggers redelivery from DeliverPolicy.
	//
	// IMPORTANT: this field is NOT live-editable on the NATS server.
	// Changing it after the consumer exists requires delete + recreate,
	// which drops ack/delivery offsets. Pick the value at construction
	// time.
	//
	// For at-least-once work-queue patterns with idempotent handlers
	// this is typically safe and yields a large IOPS reduction. See
	// docs/plans/iops-investigation/findings.md §2 for measurements
	// and §4 for the operator decision tree.
	ConsumerMemoryStorage bool

	// ConsumerReplicas overrides the underlying
	// jetstream.ConsumerConfig.Replicas value at consumer create time.
	//
	// Default: 0 (inherit the stream's replica count). Set to 1 to
	// disable consumer-state raft replication (lowest IOPS, no
	// consumer-state HA). Values between 1 and the stream's replica
	// count give intermediate IOPS/HA trade-offs.
	//
	// Constraint: must be ≤ the parent stream's Replicas. NATS rejects
	// invalid values at consumer create with error code 10126
	// ("consumer config replica count exceeds parent stream"). parti
	// does not pre-validate; the JetStream error is surfaced verbatim
	// when the underlying consumer is created or updated
	// (Queue/Static/Broadcast at Start, Dynamic at Update).
	//
	// Unlike ConsumerMemoryStorage, this field IS live-editable on
	// the NATS server via `nats consumer edit --replicas=N`; the raft
	// group expands/shrinks in place.
	ConsumerReplicas int `validate:"gte=0"`
}

CommonConfig contains configuration fields shared by all consumer types. Embedding this in each consumer's config ensures consistent naming and defaults.

These settings control the low-level behavior of the JetStream consumer, including acknowledgement policies, batching, timeouts, and redelivery.

func (*CommonConfig) SetDefaults

func (c *CommonConfig) SetDefaults() error

SetDefaults applies default values to the configuration.

func (*CommonConfig) Validate

func (c *CommonConfig) Validate() error

Validate checks configuration constraints.

type ConsumerCreateLimiter added in v2.8.0

type ConsumerCreateLimiter interface {
	// Wait blocks until the limiter grants one token or ctx is cancelled.
	Wait(ctx context.Context) error
}

ConsumerCreateLimiter gates consumer-create RPC attempts. Its Wait blocks until the limiter grants permission or ctx is cancelled (returning ctx.Err()).

Obtain one from NewConsumerCreateLimiter (a token-bucket limiter), or supply your own implementation, and pass it to WithConsumerCreateLimiter to share a single rate budget across multiple Dynamic consumers in the same process.

Lock-order contract

Wait is invoked while internal locks (the Dynamic apply/update mutexes) may be held. An implementation MUST honour context cancellation and MUST NOT call back into a Manager or Dynamic, or any operation that acquires those locks, from within Wait.

func NewConsumerCreateLimiter added in v2.8.0

func NewConsumerCreateLimiter(perSec float64, burst int) (ConsumerCreateLimiter, error)

NewConsumerCreateLimiter returns a token-bucket ConsumerCreateLimiter with the given steady rate (events/second) and burst size. It is the public constructor for a limiter that can be shared across multiple Dynamic consumers via WithConsumerCreateLimiter; for a single consumer prefer the simpler WithConsumerCreateRate.

perSec must be > 0 and burst must be >= 1. A shared limiter built this way does not emit the per-consumer throttle metrics that WithConsumerCreateRate wires up (an injected limiter bypasses the metrics adapter), since a shared budget has no single owning consumer to attribute throttle events to.

type Dynamic

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

Dynamic is a partition-aware consumer that receives assignments from a Parti Manager. It manages multiple internal consumers based on assigned partitions.

Lifecycle

Create with NewDynamic, then call Dynamic.Update to start consuming assigned partitions. Clean up with Dynamic.Stop:

consumer, err := consumer.NewDynamic(js, "stream", "worker", "orders.{{.PartitionID}}", handler)
if err != nil { log.Fatal(err) }
defer consumer.Stop(ctx)

// Start consuming partitions (typically called by Parti Manager)
if err := consumer.Update(ctx, "worker-0", partitions); err != nil { log.Fatal(err) }

Unlike Static and Queue, Dynamic does NOT have a Start method. Consumption begins when Dynamic.Update is called with a non-empty partition list.

Thread Safety

Dynamic is safe for concurrent use. Dynamic.Update calls are serialized internally to prevent race conditions during assignment changes.

func NewDynamic

func NewDynamic(
	js jetstream.JetStream,
	streamName, consumerPrefix, subjectTemplate string,
	handler MessageHandler,
	opts ...DynamicOption,
) (*Dynamic, error)

NewDynamic creates a new dynamic partition consumer.

func (*Dynamic) Capabilities added in v2.4.0

func (d *Dynamic) Capabilities() uint32

Capabilities forwards to the inner durable.WorkerConsumer; implements the [parti.CapabilityReporter] interface so the Manager's type-assertion on the registered *Dynamic updater succeeds.

func (*Dynamic) ObserveWorkerCount added in v2.8.0

func (d *Dynamic) ObserveWorkerCount(n int)

ObserveWorkerCount implements the Parti manager's fleet-size-observer optional interface. The Manager calls it with the current committed cluster worker-count N so the Dynamic consumer can retune its consumer-create limiter to min(perWorkerCeiling, clusterRate/N).

It is a no-op unless both WithConsumerCreateRate and WithConsumerCreateClusterRate were configured (createRateSetter is nil otherwise, e.g. an injected limiter). Non-blocking and non-reentrant per the observer contract: it only calls SetRate on the built-in limiter.

func (*Dynamic) SetOnStreamMissingError added in v2.5.0

func (d *Dynamic) SetOnStreamMissingError(fn func(streamName string, err error))

SetOnStreamMissingError installs a manager-side observer for stream-missing recovery exhaustion. The Parti manager calls this during Manager.Start so a stream-missing failure surfaces as a named degraded entry ("stream-missing-recovery-exhausted") rather than counting against the generic KV-error threshold.

fn receives the dynamic consumer's stream name and the wrapped types.ErrStreamMissing error chain. Calling with fn == nil clears any previously-installed observer.

Application-supplied callbacks registered via WithOnPermanentFailure do not suppress this observer: both fire for stream-missing exhaustion (application callback first, then this observer), unless the application explicitly opts out via WithSuppressManagerDegradeOnStreamMissing. See [Dynamic.onPermanentFailure] for the full dispatch rules.

Safe for concurrent use; calls before, during, or after NewDynamic are equivalent — the durable layer reads the slot at fire time.

Dynamic implements recovery.StreamMissingObserver via this method.

func (*Dynamic) SetResolverMetrics

func (d *Dynamic) SetResolverMetrics(m ResolverMetrics)

SetResolverMetrics sets the metrics collector for the ownership resolver.

This is an advanced method for observability integration. Most users do not need to call this directly.

func (*Dynamic) Stop

func (d *Dynamic) Stop(ctx context.Context) error

Stop gracefully stops all partition consumers.

Stop cancels all internal pull loops and waits for pending message processing to complete (up to the context deadline). The underlying JetStream consumers are NOT deleted; they will be garbage-collected by the server after InactiveThreshold.

If DrainOnRemove is enabled, Stop will first drain pending messages (up to DrainOnRemoveTimeout) before stopping.

Stop is terminal: subsequent manager updates (via Dynamic.Update or Dynamic.UpdateWorkerConsumer) will fail with ErrConsumerStopped. Create a new Dynamic to consume again. To avoid a loud retry loop in the manager, stop the manager first or deregister this consumer before calling Stop.

Stop is idempotent; calling it multiple times is safe.

Parameters:

  • ctx: Context with shutdown deadline. If the deadline expires, Stop returns context.DeadlineExceeded but consumers will still eventually stop.

Returns:

  • error: Context error if the wait times out; nil otherwise.

func (*Dynamic) Update

func (d *Dynamic) Update(ctx context.Context, workerID string, partitions []types.Partition) error

Update applies a new partition assignment set.

This method creates or binds durable consumers for newly assigned partitions and stops consumers for removed partitions. The underlying JetStream consumers are NOT deleted on removal; they will be garbage-collected by the server after InactiveThreshold.

Update is typically called by the Parti Manager when assignments change. On the first call, this starts consuming the assigned partitions.

Parameters:

  • ctx: Context for the update operation. Used for JetStream API calls.
  • workerID: The stable worker ID for this instance (e.g., "worker-0").
  • partitions: The new set of partitions to consume. Empty list stops all.

Returns:

Errors:

  • ErrWorkerIDMutation: Returned when workerID changes and AllowWorkerIDChange is false.
  • ErrMaxSubjectsExceeded: Returned when partition count exceeds MaxConcurrentSubjects.
  • Non-sentinel error: Returned when one or more removed partition loops fail to stop within DrainOnRemoveTimeout; the stop wait is bounded by that timeout even when DrainOnRemove is false. Tracking entries are cleared so the manager's retry converges.

func (*Dynamic) UpdateWorkerConsumer deprecated

func (d *Dynamic) UpdateWorkerConsumer(ctx context.Context, workerID string, partitions []types.Partition) error

UpdateWorkerConsumer is an alias for Dynamic.Update that implements the WorkerConsumerUpdater interface used by the Parti Manager.

Deprecated: Use Dynamic.Update for new code. This method exists for backward compatibility with code that expects the WorkerConsumerUpdater interface.

type DynamicConfig

type DynamicConfig struct {
	CommonConfig

	// StreamName is the JetStream stream to consume from.
	// Required.
	StreamName string `validate:"required"`

	// SubjectTemplate is a text/template for building subjects from partitions.
	//
	// It relies on the standard Go text/template package.
	// The template context provides a {{.PartitionID}} variable.
	//
	// Example: "orders.{{.PartitionID}}.events"
	SubjectTemplate string `validate:"required"`

	// ConsumerPrefix is the prefix for the durable consumer name.
	//
	// The final durable name is constructed dynamically for each assigned partition:
	// "<ConsumerPrefix>_<partitionID>_<hash>".
	//
	// This ensures unique, stable durability for each partition assignment.
	ConsumerPrefix string `validate:"required"`

	// ProcessingGate configures optional per-message admission control.
	//
	// When enabled, the consumer checks KV-backed ownership claims before each
	// handler invocation and NAKs deliveries for partitions it does not own.
	// This bounds — but does not eliminate — cross-worker overlap during
	// rebalances: an already-in-flight handler invocation cannot be revoked,
	// and AckWait-expiry redelivery through the shared per-partition durable
	// can hand the same message to the new owner while the old owner's handler
	// is still running. Wrap long-running handlers with [NewWIPHandler] to
	// prevent that expiry. See docs/LIFECYCLE.md "Two-Phase Handoff" for the
	// per-tier overlap contract.
	ProcessingGate *ProcessingGateConfig

	// Resolver configures the ownership resolver used when ProcessingGate is enabled.
	//
	// It defines how ownership is claimed, refreshed, and verified.
	Resolver ResolverConfig

	// PullGatingEnabled enables pre-pull ownership/state gating for consumers.
	//
	// When true, the consumer will check if it still owns the partition before
	// issuing a pull request to JetStream. This reduces "ghost" processing of
	// messages after assignment revocation.
	PullGatingEnabled bool

	// DrainOnRemove enables graceful draining when a partition assignment is revoked.
	//
	// When true, removal first polls the partition's durable until the server
	// reports no pending acknowledgements — the pull loop keeps running during
	// this wait — and only then stops the loop. Both the drain wait and the
	// loop-stop wait are bounded by DrainOnRemoveTimeout.
	DrainOnRemove bool

	// DrainOnRemoveTimeout caps the time spent draining a revoked partition
	// and bounds the wait for its pull loop to stop.
	//
	// The wait is best-effort: if loops have not stopped within this bound,
	// [Dynamic.Update] returns an error (the manager retries the apply with
	// backoff) while an already-in-flight handler invocation may still run
	// to completion. Default: 10s.
	DrainOnRemoveTimeout time.Duration `default:"10s" validate:"gte=0"`

	// MaxConcurrentSubjects limits the number of partitions (subjects) processed concurrently.
	//
	// If an assignment's deduped subject count exceeds this limit,
	// [Dynamic.Update] rejects the whole update with [ErrMaxSubjectsExceeded]
	// before making any changes; the manager retries the apply with backoff
	// and the previous owners keep consuming. Size this above the worst-case
	// per-worker partition count (e.g. after scale-down) or leave it 0
	// (unlimited).
	MaxConcurrentSubjects int `validate:"gte=0"`

	// AllowWorkerIDChange controls whether the worker's identity can change during runtime.
	//
	// Default: false (immutable once set). Changing WorkerID usually requires a restart.
	AllowWorkerIDChange bool

	// Retry configures the backoff behavior for the consume loop.
	//
	// On each iterator failure the loop sleeps for a jittered delay that grows via
	// decorrelated jitter from Base toward Max using the Multiplier factor. Seed
	// makes the sequence deterministic (useful in tests); zero uses the package-level
	// PRNG. The delay resets to zero after every successful iteration cycle.
	Retry RetryConfig

	// IteratorEscalationWindow defines the sliding time window used to aggregate
	// iterator failures for escalation detection.
	//
	// If too many iterator errors occur within this window, the consumer will
	// attempt to escalate recovery.
	//
	// Default: 60s.
	IteratorEscalationWindow time.Duration `default:"60s" validate:"gt=0"`

	// IteratorEscalationThreshold is the number of iterator failures within the
	// escalation window that triggers consumer refresh/escalation.
	//
	// Default: 3.
	IteratorEscalationThreshold int `default:"3" validate:"gt=0"`

	// PartitionRefreshMinInterval sets the minimum interval between forced claim refreshes
	// per partition when pull gating is enabled.
	//
	// This prevents excessive load on the coordination backend (KV) during high-throughput pulling.
	//
	// Default: 500ms.
	PartitionRefreshMinInterval time.Duration `default:"500ms" validate:"gt=0"`

	// IteratorFactory optionally overrides the internal iterator creation logic.
	// This is primarily used for testing to inject mock iterators.
	IteratorFactory func(cons jetstream.Consumer, batch int, expiry time.Duration) (jetstream.MessagesContext, error)

	// RecoveryStrategy defines how a recreated consumer resumes after an unexpected deletion.
	//
	// All strategies are supported. [RecoverFromLastProcessed] works with both
	// ManualAck=false (checkpoint advances automatically) and ManualAck=true
	// (checkpoint advances when the handler calls msg.Ack() or msg.DoubleAck()).
	//
	// Note: Dynamic consumers have a separate iterator-escalation detector (see
	// [DynamicConfig.IteratorEscalationWindow]) that rebinds to the existing durable
	// on repeated failures. RecoveryStrategy controls a distinct, complementary mechanism
	// that recreates the durable itself when it has been deleted.
	//
	// # WorkQueuePolicy streams
	//
	// NATS only permits [jetstream.DeliverAllPolicy] on WorkQueuePolicy streams.
	// [RecoverFromNew] and [RecoverFromLastProcessed] are incompatible and will
	// cause the first [Dynamic.Update] call to return [ErrInvalidConfig]. Use
	// [RecoverFromBeginning] or [RecoveryDisabled] for WorkQueuePolicy streams.
	RecoveryStrategy RecoveryStrategy

	// StreamMissingHook fires when the dynamic partition consumer cannot
	// create a consumer because the underlying JetStream stream is absent.
	// The hook is the operator-driven recovery escalation path; the
	// library does not recreate streams itself.
	//
	// Returning nil indicates the operator has recreated the stream. The
	// library then bumps the recovery controller's stream-epoch, resets
	// its checkpoint, re-seeds from the new consumer info, and rebuilds
	// the consumer. Returning a non-nil error (or omitting the hook
	// entirely) surfaces the loss via the F2 envelope's exhaustion path:
	// OnPermanentFailure fires with the error wrapped in
	// [types.ErrStreamMissing] so the manager routes it to enterDegraded.
	//
	// Requires [RecoveryStrategy] in {[RecoverFromLastProcessed],
	// [RecoverFromBeginning]}; [RecoveryDisabled] (default) and
	// [RecoverFromNew] are rejected at [DynamicConfig.Validate] time.
	// See [types.StreamMissingHook] godoc for the full operator
	// contract (same-durable-name preservation, compatible-config
	// restore, fresh-stream replay semantics).
	StreamMissingHook types.StreamMissingHook

	// OnPermanentFailure is the application-facing observability seam for
	// permanent partition-consumer failure. It fires exactly once per
	// partition consumer when the iterator-creation envelope or the
	// stream-missing Site B detour exhausts its attempt budget; the
	// consumption loop exits immediately afterwards.
	//
	// The callback runs synchronously on the consumption loop's goroutine
	// and MUST be non-blocking. The error preserves the wrap chain:
	// errors.Is(err, [types.ErrStreamMissing]) is true when stream-missing
	// exhaustion drove the failure, so applications can route those
	// distinctly from generic envelope exhaustion.
	//
	// Registering this callback does NOT disable the Parti manager's
	// auto-degraded route: for stream-missing exhaustion the manager
	// observer is notified after this callback returns. Use
	// [DynamicConfig.SuppressManagerDegradeOnStreamMissing] (option form:
	// [WithSuppressManagerDegradeOnStreamMissing]) to opt out explicitly.
	// See [WithOnPermanentFailure] for the full contract.
	OnPermanentFailure func(subject string, err error)

	// SuppressManagerDegradeOnStreamMissing disables the Parti manager's
	// auto-degraded route for stream-missing recovery exhaustion. By default
	// the manager observer is notified even when OnPermanentFailure is set
	// (both fire; application callback first). Set this only when the
	// application deliberately owns degrade/rotation signaling itself.
	SuppressManagerDegradeOnStreamMissing bool

	// RecoveryRetry tunes the bounded-retry envelope wrapped around
	// the iterator-creation path and the stream-missing Site B
	// detour. Zero-valued fields fall back to the durable layer's
	// defaults (MaxAttempts=8, BaseBackoff=500ms, MaxBackoff=30s).
	// See [WithRecoveryRetry] for the option form.
	RecoveryRetry RecoveryRetryConfig
}

DynamicConfig configures a Dynamic consumer.

func (*DynamicConfig) SetDefaults

func (c *DynamicConfig) SetDefaults() error

SetDefaults applies default values to the configuration.

func (*DynamicConfig) Validate

func (c *DynamicConfig) Validate() error

Validate checks configuration constraints.

type DynamicOption

type DynamicOption interface {
	// contains filtered or unexported methods
}

DynamicOption applies only to Dynamic consumers.

This interface allows both universal Options and Dynamic-specific options (like WithProcessingGate) to be passed to NewDynamic.

func WithAllowWorkerIDChange

func WithAllowWorkerIDChange(enabled bool) DynamicOption

WithAllowWorkerIDChange enables worker ID mutability (advanced).

func WithConsumerCreateClusterRate added in v2.8.0

func WithConsumerCreateClusterRate(clusterPerSec float64) DynamicOption

WithConsumerCreateClusterRate makes consumer-create rate limiting fleet-size-aware. Given a cluster-wide target (events/second), each worker enforces min(perWorkerCeiling, clusterPerSec/N), where perWorkerCeiling is the rate from WithConsumerCreateRate and N is the cluster worker-count the Parti Manager observes and pushes to this consumer.

This bounds the STEADY-STATE cluster-wide create rate to clusterPerSec instead of N*perWorkerCeiling. The per-worker ceiling still caps any single worker during fleet-size transitions.

Requires WithConsumerCreateRate (which supplies burst and the ceiling) and the built-in limiter; it is rejected at NewDynamic when used alone or with an injected WithConsumerCreateLimiter (an injected, possibly shared limiter is not adaptively retuned). clusterPerSec must be >= 0; 0 disables the overlay (static per-worker behaviour).

Applies only to Dynamic.

func WithConsumerCreateLimiter added in v2.8.0

func WithConsumerCreateLimiter(l ConsumerCreateLimiter) DynamicOption

WithConsumerCreateLimiter injects a custom or shared ConsumerCreateLimiter that gates every physical CreateOrUpdateConsumer attempt. Use this when multiple Dynamic consumers in the same process should share one rate budget across all their consumer creates; build the shared limiter with NewConsumerCreateLimiter. For a single consumer prefer WithConsumerCreateRate.

Precedence rules (any option order):

  • A non-nil injected limiter wins over WithConsumerCreateRate.
  • Passing nil is a no-op: it does NOT clear a configured rate limiter.

An injected or shared limiter bypasses the per-consumer throttle metrics that WithConsumerCreateRate wires up (a shared budget has no single owning consumer to attribute throttle events to).

Lock-order contract

The injected limiter's Wait(ctx) is invoked while the Dynamic apply/update locks may be held. It MUST honour context cancellation and MUST NOT call back into Manager, Dynamic, or any operation that acquires those locks. See ConsumerCreateLimiter.

Applies only to Dynamic.

func WithConsumerCreateRate added in v2.8.0

func WithConsumerCreateRate(perSec float64, burst int) DynamicOption

WithConsumerCreateRate enables a per-attempt token-bucket rate limit on consumer-create RPCs (initial assignment add + recovery recreation), at the given steady rate (events/second) with the given burst size.

This is opt-in. When not configured (the default), behaviour is unchanged.

The rate applies to every physical CreateOrUpdateConsumer attempt, including retries, so transient-error storms are paced at the same rate as normal creates — preventing up to 3× overshoot that would otherwise occur if gating were per-logical-create only.

Validation: perSec must be >= 0; perSec == 0 leaves the limiter disabled (no rate limiting); burst must be >= 1 when perSec > 0. Invalid values are rejected at NewDynamic, which returns an error wrapping ErrInvalidConfig when perSec < 0, or when perSec > 0 with burst < 1. Use WithConsumerCreateLimiter to supply a custom or shared limiter instead.

Sizing guidance: rate ≈ cluster-create-budget / max-workers. Recommended starting values (validate by load test): rate ≈ 100/s, burst ≈ 256.

Interaction with handoff and readiness

A paced apply holds the Dynamic apply lock for its duration, serialising subsequent applies and blocking Close. With the processing gate OFF, enabling pacing lengthens the period during which old and new owners are both active (processing-overlap window); co-enable the processing gate / pull-gating to suppress that overlap. A large cold start (e.g. 20 000 partitions at 100/s ≈ 200s) may trip StartupTimeout (default 60s) — size it accordingly or accept a one-shot startup-degraded rotation.

Applies only to Dynamic.

func WithDrainOnRemove

func WithDrainOnRemove(enabled bool, timeout time.Duration) DynamicOption

WithDrainOnRemove configures drain behavior on subject removal.

When enabled, removed partition loops drain buffered messages before shutting down. Draining is bounded by DrainOnRemoveTimeout; if the loops fail to stop within that bound, Dynamic.Update returns an error and the manager retries the apply. An already-in-flight handler invocation may still run to completion (best-effort, not a zero-overlap guarantee).

Parameters:

  • enabled: Whether to enable graceful draining
  • timeout: Maximum time to wait for draining (ignored if <= 0)

func WithIteratorEscalation

func WithIteratorEscalation(window time.Duration, threshold int) DynamicOption

WithIteratorEscalation configures iterator failure escalation.

Only supported by Dynamic consumers. The escalation mechanism uses a sliding window to detect bursts of iterator failures and triggers consumer recreation when the threshold is exceeded.

func WithMaxConcurrentSubjects

func WithMaxConcurrentSubjects(n int) DynamicOption

WithMaxConcurrentSubjects caps concurrent per-subject consumers.

func WithOnPermanentFailure added in v2.5.0

func WithOnPermanentFailure(fn func(subject string, err error)) DynamicOption

WithOnPermanentFailure installs a callback fired exactly once per partition consumer when its iterator-creation retry envelope or stream-missing Site B detour exhausts its attempt budget. The callback is the primary observability seam between the bounded recovery loops and the application's manager / readiness wiring.

The callback fires synchronously on the consumption loop's goroutine immediately before the loop exits. It MUST be non-blocking; long work should be offloaded to a goroutine inside the callback so partition teardown is not delayed.

The error passed to the callback preserves the wrap chain of the underlying cause. When stream-missing exhaustion drove the failure, errors.Is(err, [parti.ErrStreamMissing]) is true; the application can branch on that to distinguish "stream is gone, operator must recreate it" from "iter-create budget exhausted for some other reason".

Interaction with the Parti manager's auto-degraded route

Registering this callback does NOT disable the Parti manager's auto-degraded route. When a Dynamic is wired into a [parti.Manager] (directly or via a [parti.CompositeConsumerUpdater]), the manager installs an observer that — for stream-missing exhaustion only — calls [parti.Hooks.OnError] with the wrapped error and then transitions to Degraded mode with reason "stream-missing-recovery-exhausted" so the readiness probe rotates the pod. Both this callback and the manager observer fire (application callback first) for stream-missing exhaustion.

Applications that deliberately own degrade/rotation signaling themselves (e.g. they forward stream-missing events to their own readiness wiring inside this callback) can suppress the manager observer explicitly via WithSuppressManagerDegradeOnStreamMissing.

Applies only to Dynamic. Optional; if unset, exhaustion is logged at WARN with metric `iterator_restart{reason="recovery_exhausted"}` or `iterator_restart{reason="stream_missing_exhausted"}` but no callback fires (unless the [parti.Manager] observer is wired, in which case stream-missing exhaustion routes through it).

func WithPartitionRefreshMinInterval

func WithPartitionRefreshMinInterval(d time.Duration) DynamicOption

WithPartitionRefreshMinInterval sets the min interval for partition refresh.

func WithProcessingGate

func WithProcessingGate(cfg *ProcessingGateConfig) DynamicOption

WithProcessingGate enables processing gate with given config.

func WithPullGating

func WithPullGating(enabled bool) DynamicOption

WithPullGating enables pre-pull ownership checks.

func WithRecoveryRetry added in v2.5.0

func WithRecoveryRetry(cfg RecoveryRetryConfig) DynamicOption

WithRecoveryRetry tunes the bounded-retry envelope wrapped around the dynamic partition consumer's iterator-creation path and the stream-missing Site B detour. See RecoveryRetryConfig for the individual fields and their defaults.

Lowering MaxAttempts / BaseBackoff / MaxBackoff is useful when an operator prefers fast pod-rotation on a sustained iter-create failure (e.g. tighter readiness SLAs). Raising them tolerates longer NATS partitions before escalation to OnPermanentFailure and degraded mode.

Applies only to Dynamic. Zero-valued fields fall back to the durable layer's defaults; partial overrides are supported.

func WithResolver

func WithResolver(cfg ResolverConfig) DynamicOption

WithResolver configures the ownership resolver.

func WithStreamMissingHook added in v2.5.0

func WithStreamMissingHook(hook types.StreamMissingHook) DynamicOption

WithStreamMissingHook installs the operator-driven escalation invoked when the dynamic partition consumer's recovery flow detects the underlying JetStream stream is absent. Configuring the hook is the way an application learns "Parti's recovery loop has given up trying to find your stream; please recreate it." See types.StreamMissingHook for the full operator contract, including same-durable-name preservation, compatible-config reconciliation, and the post-hook checkpoint reset / epoch fence semantics.

Requires a non-disabled RecoveryStrategy. Only RecoverFromLastProcessed (at-least-once, the common case) and RecoverFromBeginning (replay-all, intentional duplicate processing) are accepted at NewDynamic / DynamicConfig.Validate time. RecoveryDisabled and RecoverFromNew are rejected because the recreated-stream replay override that prevents the fresh-stream skip hazard only applies in the at-least-once and from-beginning branches.

Without a hook configured, a stream-missing classification surfaces via the iterator-creation envelope's permanent-failure path: the error wraps types.ErrStreamMissing and the Parti manager routes it through its degraded-mode wiring so a readiness probe can rotate the pod.

Applies only to Dynamic. The hook must be safe to call from a recovery goroutine and should return promptly; long-running hooks delay the consumer rebuild and keep the F2 envelope's attempt budget ticking.

func WithSuppressManagerDegradeOnStreamMissing added in v2.7.0

func WithSuppressManagerDegradeOnStreamMissing() DynamicOption

WithSuppressManagerDegradeOnStreamMissing disables the Parti manager's auto-degraded route for stream-missing recovery exhaustion on this Dynamic consumer.

By default, when a Dynamic is wired into a [parti.Manager], stream-missing exhaustion notifies the manager observer (calling [parti.Hooks.OnError] with the wrapped error and entering Degraded with reason "stream-missing-recovery-exhausted" so the readiness probe rotates the pod) IN ADDITION to any application callback registered via WithOnPermanentFailure. Suppress this only when the application deliberately owns degrade/rotation signaling itself — e.g. it forwards stream-missing events to its own readiness wiring inside its OnPermanentFailure callback.

Applies only to Dynamic.

type GateMetrics

type GateMetrics interface {
	// IncGateNAK increments the NAK counter by reason
	// (e.g., "unknown_ownership", "non_owner", "owner_disallowed_state").
	IncGateNAK(reason string)

	// ObserveGateNakDelay records the NAK delay applied by the gate.
	ObserveGateNakDelay(d time.Duration)
}

GateMetrics captures optional Processing Gate metrics.

Implementations should be non-blocking. When nil, metrics are not recorded.

type MessageHandler

type MessageHandler interface {
	// Handle processes a JetStream message.
	// Return nil to indicate successful processing; return an error for failures.
	// Ack/Nak behavior depends on the consumer's ManualAck setting:
	//   - ManualAck=false: nil → Ack, error → Nak (default behavior)
	//   - ManualAck=true: handler must call msg.Ack/Nak/Term explicitly
	Handle(ctx context.Context, msg jetstream.Msg) error
}

MessageHandler processes JetStream messages. This is the unified handler interface for all consumer types in this package.

func NewWIPHandler

func NewWIPHandler(handler MessageHandler, cfg WIPConfig) MessageHandler

NewWIPHandler creates a work-in-progress handler wrapper.

The wrapper sends periodic msg.InProgress() calls while the underlying handler is processing, preventing JetStream from redelivering the message before processing completes.

Parameters:

  • handler: The underlying handler to wrap (nil returns nil)
  • cfg: Heartbeat configuration (interval, optional min interval, and logger)

Returns:

  • MessageHandler: The wrapped handler, or the original handler when disabled

Behavior:

  • cfg.Interval <= 0: Returns handler unchanged (heartbeats disabled)
  • cfg.Interval < MinInterval: Interval is clamped to MinInterval
  • handler == nil: Returns nil

Example:

wrapped := consumer.NewWIPHandler(myHandler, consumer.WIPConfig{
    Interval: 10 * time.Second, // AckWait is 30s, so 10s is safe
    Logger:   logger,
})

type MessageHandlerFunc

type MessageHandlerFunc func(ctx context.Context, msg jetstream.Msg) error

MessageHandlerFunc adapts a function to MessageHandler.

func (MessageHandlerFunc) Handle

func (f MessageHandlerFunc) Handle(ctx context.Context, msg jetstream.Msg) error

Handle implements MessageHandler.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option is a functional option that applies to all consumer types.

Common options (like WithLogger, WithAckWait) return this interface, allow them to be used with any consumer constructor (NewQueue, NewBroadcast, etc.).

func WithAckPolicy

func WithAckPolicy(p jetstream.AckPolicy) Option

WithAckPolicy sets the JetStream ack policy.

func WithAckWait

func WithAckWait(d time.Duration) Option

WithAckWait sets the time allowed for processing before redelivery.

If a message is not acknowledged within this duration, the server will redeliver it. Should be longer than expected processing time. Values <= 0 are ignored and the default (30s) is retained.

Parameters:

  • d: Acknowledgement wait duration (must be > 0)

func WithBatchSize

func WithBatchSize(n int) Option

WithBatchSize sets the maximum number of messages to pull per request.

Higher batch sizes improve throughput but increase memory usage. Values <= 0 are ignored and the default (1) is retained.

Parameters:

  • n: Batch size (must be > 0)

func WithConsumerMemoryStorage added in v2.4.0

func WithConsumerMemoryStorage(enabled bool) Option

WithConsumerMemoryStorage sets the underlying jetstream.ConsumerConfig.MemoryStorage flag on consumer create.

When true, the consumer's delivery and ack state lives in memory rather than inheriting the stream's storage type. The published message log is unaffected — it stays wherever the stream is configured to live.

Trade-off: consumer state is NOT durable across coordinated cluster restart. With Replicas ≥ 2 (the default at stream R ≥ 2), single- node failure is survivable via raft peers. With Replicas = 1, any failure of the consumer-state holder triggers redelivery from DeliverPolicy.

IMPORTANT: this option is NOT live-editable on the NATS server. Changing the value after the consumer exists requires delete + recreate, which drops ack/delivery offsets. If parti starts with a different value than the existing consumer was created with, the underlying CreateOrUpdateConsumer call fails — NATS rejects the storage-type change — and the error surfaces at Start (or Update, for Dynamic). parti does NOT auto-delete; that would silently drop ack state.

Migration recipe (operator-driven, requires acknowledged at-least-once redelivery from DeliverPolicy):

  1. Stop the parti workers (or scale to zero).
  2. nats consumer rm <STREAM> <CONSUMER> # drop old consumer
  3. Restart parti with WithConsumerMemoryStorage(true). The consumer is recreated with the new storage type.

Step 2 wipes ack/delivery offsets; any in-flight messages will be redelivered from DeliverPolicy. Only safe for at-least-once pipelines with idempotent handlers.

Measured impact: see docs/plans/iops-investigation/findings.md §3 for the cost decomposition and §4 for the decision tree.

Default: false (inherit stream storage type).

func WithConsumerReplicas added in v2.4.0

func WithConsumerReplicas(n int) Option

WithConsumerReplicas overrides the underlying jetstream.ConsumerConfig.Replicas value at consumer create time.

0 (the default) inherits the parent stream's replica count. 1 disables consumer-state raft replication (lowest IOPS, no consumer-state HA). Values between 1 and the stream's Replicas give intermediate IOPS/HA trade-offs.

Constraints (validated server-side by NATS, surfaced verbatim when the underlying JetStream consumer is created or updated — parti does not pre-validate. The error surfaces at different times per consumer type: Queue/Static/Broadcast at Start; Dynamic at Update):

  • On LimitsPolicy streams (parti's default): must be 0 ≤ Replicas ≤ stream.Replicas. Values above stream.Replicas are rejected with NATS error code 10126 ("consumer config replica count exceeds parent stream").
  • On InterestPolicy and WorkQueuePolicy streams: nonzero Replicas must EQUAL stream.Replicas. So on a WorkQueuePolicy stream with stream.Replicas=3, only Replicas ∈ {0, 3} is accepted; Replicas=1 is rejected. This is a NATS-server-side rule, not a parti choice. Practically, ANY parti consumer used on an InterestPolicy or WorkQueuePolicy stream cannot use Replicas=1 — pair the consumer with WithConsumerMemoryStorage(true) alone for the durability- preserving IOPS reduction on those retention policies.

Unlike WithConsumerMemoryStorage, this option IS live-editable on the NATS server (`nats consumer edit --replicas=N`); the raft group expands/shrinks in place and converges within seconds.

Negative values are silently ignored (defensive guard; matches existing With* style).

Default: 0 (inherit stream replicas).

func WithFetchTimeout

func WithFetchTimeout(d time.Duration) Option

WithFetchTimeout sets the max time to wait when pulling a batch.

func WithInactiveThreshold

func WithInactiveThreshold(d time.Duration) Option

WithInactiveThreshold sets how long an idle consumer is kept before cleanup.

func WithLogger

func WithLogger(l types.Logger) Option

WithLogger sets the logger for consumer operations.

If nil is passed, the default no-op logger is retained.

Parameters:

  • l: Logger implementation (nil is ignored)

func WithManualAck

func WithManualAck(enabled bool) Option

WithManualAck enables manual acknowledgement.

When true, the handler must call msg.Ack(), msg.Nak(), or msg.Term() explicitly. When false (default), returning nil auto-acks and returning an error auto-naks.

RecoverFromLastProcessed is compatible with both modes. When ManualAck=true the framework intercepts msg.Ack() / msg.DoubleAck() to advance the checkpoint; the handler still controls when to ack.

func WithMaxAckPending

func WithMaxAckPending(n int) Option

WithMaxAckPending limits in-flight unacknowledged messages.

func WithMaxDeliver

func WithMaxDeliver(n int) Option

WithMaxDeliver sets the maximum redelivery attempts.

func WithMaxWaiting

func WithMaxWaiting(n int) Option

WithMaxWaiting caps outstanding pull requests.

func WithMetrics

func WithMetrics(m types.WorkerConsumerMetrics) Option

WithMetrics sets the metrics collector for worker consumer operations.

The consumer package only uses WorkerConsumerMetrics methods. Passing a full MetricsCollector (which embeds WorkerConsumerMetrics) also works.

If nil is passed, the default no-op collector is retained.

Parameters:

  • m: Metrics collector implementation (nil is ignored)

func WithPullHeartbeatCap added in v2.10.0

func WithPullHeartbeatCap(d time.Duration) Option

WithPullHeartbeatCap bounds the derived nats.go PullHeartbeat value used by the consumer's pull loop. The heartbeat is normally FetchTimeout/2, clamped to nats.go's PullHeartbeat validity range [500ms, 30s]; when d > 0, the derived heartbeat is further capped to d.

The heartbeat drives missed-heartbeat detection: nats.go's ErrNoHeartbeat fires at roughly 2x the heartbeat, which is how a deleted durable consumer is detected. Raising FetchTimeout to reduce idle pull-request churn also raises the derived heartbeat and therefore that detection latency; this cap bounds it independent of FetchTimeout.

0 (default) disables the cap: the heartbeat stays FetchTimeout/2, capped only at 30s — this is the behavior with the option unset. A nonzero d outside [500ms, 30s] (nats.go's PullHeartbeat bounds) is rejected at construction with ErrInvalidConfig.

func WithRecoveryStrategy added in v2.1.0

func WithRecoveryStrategy(strategy RecoveryStrategy) Option

WithRecoveryStrategy enables strategy-aware auto-recovery on consumer deletion and controls the DeliverPolicy used when recreating the consumer.

By default, recovery is disabled (RecoveryDisabled). When a strategy is set, iterator errors that confirm durable deletion trigger automatic recreation using the chosen DeliverPolicy. Without a strategy, the consumer retries with backoff but does not recreate the durable.

Strategy trade-offs:

  • RecoverFromNew: no data loss risk, but messages published during the outage are skipped. Good default for most cases.
  • RecoverFromLastProcessed: at-least-once delivery from the last acked message. No full replay. Works with both ManualAck modes — see RecoverFromLastProcessed.
  • RecoverFromBeginning: full stream replay from message 1. Safe only for small or idempotent workloads; causes a replay storm on large streams.

Per-consumer support:

WorkQueuePolicy streams

NATS only allows jetstream.DeliverAllPolicy when creating consumers on WorkQueuePolicy streams. RecoverFromNew and RecoverFromLastProcessed are incompatible and will be rejected at startup:

Use RecoverFromBeginning or RecoveryDisabled on WorkQueuePolicy streams.

type ProcessingGateConfig

type ProcessingGateConfig struct {
	// Enabled toggles the gate.
	// Default: false (gate disabled).
	Enabled bool

	// AllowedStates defines which handoff states permit message processing.
	// If empty, defaults to [types.StateStable].
	//
	// Common configurations:
	//  - [types.StateStable]: Strict consistency (default). Messages only processed when ownership is stable.
	//  - [types.StateStable, types.StateHandoff]: Higher availability. Allows processing during handoff.
	AllowedStates []types.HandoffState `validate:"omitempty,min=1"`

	// WarmupDuration, when >0, enables a warm-up phase during which only
	// WarmupAllowedStates are permitted. After the duration elapses, AllowedStates
	// take effect for steady-state processing.
	WarmupDuration time.Duration `validate:"gte=0"`

	// WarmupAllowedStates defines which states are permitted during the warm-up phase.
	// If empty and WarmupDuration>0, defaults to Stable-only.
	WarmupAllowedStates []types.HandoffState

	// NakDelay is the base delay for NAK when the worker is not the owner or
	// the state is disallowed.
	// Default: 100ms.
	NakDelay time.Duration `default:"100ms" validate:"gte=0"`

	// NakJitter is a fractional jitter in [0.0, 1.0] applied to NakDelay.
	// Default: 0.0 (no jitter).
	NakJitter float64 `validate:"gte=0,lte=1"`

	// Debug enables verbose logging for NAK decisions (non-owner, disallowed state, etc).
	Debug bool

	// Metrics optionally records processing gate metrics (NAK reasons, delays).
	Metrics GateMetrics
}

ProcessingGateConfig configures the optional processing gate.

When enabled, a Dynamic consumer checks KV-backed ownership claims before each handler invocation (per-message admission control) and NAKs messages when the worker is not the owner or when the handoff state is not in the allowed set. This bounds, but does not eliminate, split-brain processing during rebalances: a handler invocation that has already started cannot be revoked. See docs/LIFECYCLE.md "Two-Phase Handoff" for the per-tier overlap contract.

type Queue

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

Queue is a load-balanced consumer where multiple instances share one durable. Each message is delivered to exactly one instance (queue group semantics).

Unlike Broadcast (fan-out to all), Queue distributes messages across replicas. This is useful for classic worker queue patterns where each message should be processed by exactly one worker.

Lifecycle

Create with NewQueue, start consumption with Queue.Start, and clean up with Queue.Stop:

q, err := consumer.NewQueue(js, "stream", "consumer", "subject.>", handler)
if err != nil { log.Fatal(err) }
defer q.Stop(ctx)

if err := q.Start(ctx); err != nil { log.Fatal(err) }

Thread Safety

Queue is safe for concurrent use. Queue.Start and Queue.Stop are serialized internally.

func NewQueue

func NewQueue(
	js jetstream.JetStream,
	streamName, consumerName, filterSubject string,
	handler MessageHandler,
	opts ...QueueOption,
) (*Queue, error)

NewQueue creates a new queue (load-balanced) consumer.

func (*Queue) Start

func (q *Queue) Start(ctx context.Context) error

Start begins consuming messages.

func (*Queue) Stop

func (q *Queue) Stop(ctx context.Context) error

Stop gracefully stops the consumer.

If ctx expires before the pull loop exits, Stop returns the context error and the consumer still counts as started: a subsequent Queue.Start fails, and a second Stop call (which waits for the loop again) is required before the consumer can be restarted.

type QueueConfig

type QueueConfig struct {
	CommonConfig

	// StreamName is the name of the JetStream stream to consume from.
	// This field is required and must match an existing stream.
	StreamName string `validate:"required"`

	// FilterSubject is the subject filter to consume from.
	// Supports wildcards (e.g., "orders.*", "events.>").
	// Only messages matching this filter will be delivered to the consumer.
	FilterSubject string `validate:"required"`

	// ConsumerName is the durable consumer name.
	// This name must be unique within the stream.
	// For Queue consumers, this name identifies the shared consumer group;
	// multiple instances using the same ConsumerName will share the load.
	ConsumerName string `validate:"required"`

	// Retry configures the backoff behavior for control-plane operations
	// (e.g., initial connection, creating the consumer).
	Retry RetryConfig

	// IteratorFactory optionally overrides the internal iterator creation logic.
	// This is primarily used for testing to inject mock iterators.
	IteratorFactory func(cons jetstream.Consumer, batch int, expiry time.Duration) (jetstream.MessagesContext, error)

	// RecoveryStrategy defines how a recreated consumer resumes after an unexpected deletion.
	//
	// Supported values for Queue consumers:
	//   - [RecoverFromNew]: skip messages published during the outage.
	//   - [RecoverFromBeginning]: replay all messages from the start of the stream.
	//
	// [RecoverFromLastProcessed] is rejected at construction time because Queue shares
	// one durable across all replicas — per-process checkpointing is nondeterministic.
	//
	// # WorkQueuePolicy streams
	//
	// NATS only permits [jetstream.DeliverAllPolicy] on WorkQueuePolicy streams.
	// [RecoverFromNew] maps to [jetstream.DeliverNewPolicy] and is therefore incompatible:
	// [Queue.Start] rejects the combination with [ErrInvalidConfig]. The pre-flight
	// check is best-effort — transient failures to fetch stream info are ignored
	// (see [CheckWorkQueueRecoveryCompat]) — so an incompatible combination can
	// slip past Start and only surface when recovery first misbehaves.
	// Use [RecoverFromBeginning] or [RecoveryDisabled] for WorkQueuePolicy streams.
	RecoveryStrategy RecoveryStrategy
}

QueueConfig configures a Queue consumer. Embeds CommonConfig for shared fields.

func DefaultQueueConfig

func DefaultQueueConfig() QueueConfig

DefaultQueueConfig returns a QueueConfig with sensible defaults. Note: Required fields (StreamName, ConsumerName, FilterSubject) must still be set by the user.

func (*QueueConfig) SetDefaults

func (c *QueueConfig) SetDefaults() error

SetDefaults sets default values for the configuration.

func (*QueueConfig) Validate

func (c *QueueConfig) Validate() error

Validate checks configuration constraints.

type QueueOption

type QueueOption interface {
	// contains filtered or unexported methods
}

QueueOption applies only to Queue consumers.

This interface allows both universal Options and Queue-specific options to be passed to NewQueue. Broadcast/Static/Dynamic specific options do not implement this, enforcing type safety.

type RecoveryRetryConfig added in v2.5.0

type RecoveryRetryConfig = durable.RecoveryRetryConfig

RecoveryRetryConfig tunes the bounded-retry envelope wrapped around the dynamic partition consumer's iterator-creation path and the stream-missing Site B detour. The envelope caps consecutive failures at [RecoveryRetryConfig.MaxAttempts]; exhaustion fires Dynamic's OnPermanentFailure / manager observer routes once and the consumer loop exits.

Defaults (when fields are zero): MaxAttempts=8, BaseBackoff=500ms, MaxBackoff=30s, Jitter=0.2. Lowering these accelerates the degraded-mode transition for operators willing to trade short-term reconnection patience for faster pod rotation; raising them tolerates longer NATS partitions before escalation.

type RecoveryStrategy added in v2.1.0

type RecoveryStrategy = recovery.Strategy

RecoveryStrategy defines how a recreated consumer decides where to resume after an unexpected deletion.

Use WithRecoveryStrategy to enable auto-recovery on consumer types that support durable recreation semantics.

type ResolverConfig

type ResolverConfig struct {
	// OwnershipResolver (advanced) supplies custom ownership lookups.
	// When nil and ProcessingGate.Enabled is true, a claim-based resolver
	// is auto-created using HandoffBucketName/HandoffClaimsPrefix. When
	// non-nil, it overrides the automatic resolver creation.
	OwnershipResolver types.OwnershipResolver

	// HandoffBucketName specifies the KV bucket name for handoff claims.
	// When ProcessingGate.Enabled is true and this is set, the Dynamic consumer
	// will automatically get/create the KV bucket and start a claim resolver.
	// Should match the HandoffBucket in parti.Config.KVBuckets for consistency.
	// Default: "parti-handoff".
	HandoffBucketName string `default:"parti-handoff"`

	// HandoffClaimsPrefix is the key prefix for handoff claims in the KV bucket.
	// Default: "claims/".
	HandoffClaimsPrefix string `default:"claims/"`

	// BatchWindow is the coalescing window for batching claim updates into the
	// auto-created claim-based resolver when ProcessingGate is enabled.
	// If zero, a default (5ms) is used. Ignored when a custom OwnershipResolver
	// is provided.
	BatchWindow time.Duration `default:"5ms" validate:"gte=0"`

	// BatchMaxItems caps the number of unique partition updates coalesced into a
	// single apply. If zero, a default (1024) is used. Ignored when a custom
	// OwnershipResolver is provided.
	BatchMaxItems int `default:"1024" validate:"gt=0"`

	// ReconcileInterval is the cadence at which the auto-created claim-based
	// resolver re-lists the handoff bucket and reconciles its cache against
	// KV. This is the recovery mechanism for silent watcher stalls (the
	// nats.go KV watcher does NOT surface NATS server restarts as Updates()
	// channel close; only an explicit Stop / connection close / subscription
	// teardown does). After such a stall the cache stays stale for at most
	// one reconcile period.
	//
	// Choose a value shorter than parti.Config.ExtendedApplyGracePeriod
	// (default 5 × HeartbeatTTL) so the leader-side audit cannot escalate
	// audit_repair while the worker's resolver cache is still stale. With
	// the default HeartbeatTTL=15s, this gives an audit grace of 75s and
	// the default 30s reconcile is comfortably inside it. If you have tuned
	// HeartbeatTTL below ~6s, set ReconcileInterval to HeartbeatTTL or
	// HeartbeatTTL/2.
	//
	// Idle-bucket cost note: reconcile passes are scan-gated — when a
	// cheap stream-position probe proves the bucket unchanged since the
	// last full pass, the pass skips its key walk entirely (no ephemeral
	// consumer creation). Shortening this interval therefore no longer
	// multiplies steady-state consumer churn on an idle handoff bucket;
	// the interval still bounds worst-case staleness recovery as described
	// above.
	//
	// Zero uses the default (30s). Negative values are rejected at startup.
	// Ignored when a custom OwnershipResolver is provided.
	ReconcileInterval time.Duration `default:"30s" validate:"gte=0"`
}

ResolverConfig configures the ownership resolver used when ProcessingGate is enabled.

It defines how ownership claims are tracked and verified. When ProcessingGate.Enabled is true and no custom OwnershipResolver is provided, a claim-based resolver is automatically created using the KV bucket specified by HandoffBucketName.

type ResolverMetrics

type ResolverMetrics interface {
	// ObserveVisibilityLag records the lag between claim LastUpdated and
	// when the resolver observes the update (watcher delivery time).
	ObserveVisibilityLag(d time.Duration)

	// SetCacheSize sets the current resolver cache size (number of partitions
	// with visible claims).
	SetCacheSize(n int)

	// IncUpdate increments update counters by operation type
	// (e.g., "upsert", "delete").
	IncUpdate(op string)

	// ObserveBatch records properties of an applied batch: the number of
	// coalesced items and how long it took to apply to the cache.
	ObserveBatch(size int, applyDuration time.Duration)

	// IncBatchFlush increments a counter for batch flush events by reason
	// (e.g., "timer", "maxitems").
	IncBatchFlush(reason string)

	// IncWatcherRestart increments a counter when the resolver's KV watcher
	// is re-established after closure or establishment failure.
	// Reasons: "channel_closed", "drift_detected", "establish_failed".
	IncWatcherRestart(reason string)

	// IncReconcileRescue increments when reconcileScan applies any
	// change to the cache — i.e., the reconciler observed drift between
	// KV and the in-memory cache and rescued the cache. Persistent
	// non-zero values are strong evidence the KV watcher is silently
	// stalled (the nats.go KV watcher does not surface NATS server
	// restarts as Updates() channel close, so the supervisor's
	// channel_closed restart path will not fire for that trigger).
	// Operators should alert on this metric being non-zero across
	// multiple consecutive reconcile cycles.
	IncReconcileRescue()
}

ResolverMetrics captures optional ownership resolver metrics. Implementations should be non-blocking. When nil, metrics are not recorded.

type RetryConfig

type RetryConfig struct {
	// Backoff is the delay between retries for control-plane operations.
	// Default: 100ms.
	Backoff time.Duration `default:"100ms" validate:"gte=0"`

	// Max caps the jittered backoff.
	// Default: 5s.
	Max time.Duration `default:"5s" validate:"gte=0,gtefield=Backoff"`

	// Multiplier grows the backoff window for decorrelated jitter.
	// Default: 1.6.
	Multiplier float64 `default:"1.6" validate:"gte=1"`

	// Base is the base backoff used for decorrelated jitter retries.
	// Default: 200ms. If zero and Backoff is set, Base falls back to Backoff.
	Base time.Duration `default:"200ms" validate:"gte=0"`

	// Seed optionally seeds the jitter RNG for deterministic tests.
	// When zero, a random seed is used.
	Seed int64
}

RetryConfig groups retry backoff settings.

type Static

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

Static is a consumer bound to a single, fixed partition. Use for StatefulSet deployments where pod ordinal determines partition.

Lifecycle

Create with NewStatic, start consumption with Static.Start, and clean up with Static.Stop:

consumer, err := consumer.NewStatic(js, "stream", "consumer-0", "events.{{partition}}", 10, 0, handler)
if err != nil { log.Fatal(err) }
defer consumer.Stop(ctx)

if err := consumer.Start(ctx); err != nil { log.Fatal(err) }

Thread Safety

Static is safe for concurrent use. Static.Start and Static.Stop are serialized internally.

func NewStatic

func NewStatic(
	js jetstream.JetStream,
	streamName, consumerName, subjectPattern string,
	numPartitions, partitionIndex int,
	handler MessageHandler,
	opts ...StaticOption,
) (*Static, error)

NewStatic creates a new static partition consumer.

func (*Static) Partition

func (s *Static) Partition() int

Partition returns the partition index this consumer handles.

Returns:

  • int: The zero-based partition index (0 to NumPartitions-1).

func (*Static) Start

func (s *Static) Start(ctx context.Context) error

Start begins consuming messages in a background goroutine.

The consumer creates or binds to a durable JetStream consumer and starts a pull loop. Messages are delivered to the handler configured at creation.

Start may only be called once. Calling Start on an already-started consumer returns an error. After Static.Stop is called, Start returns ErrConsumerStopped; create a new instance to consume again.

Parameters:

  • ctx: Context for lifecycle control. Cancellation stops the consumer.

Returns:

  • error: Non-nil if the consumer is already started, ErrConsumerStopped if the consumer has been stopped, or a JetStream consumer creation error.

func (*Static) Stop

func (s *Static) Stop(ctx context.Context) error

Stop gracefully stops the consumer.

Stop cancels the internal pull loop and waits for pending message processing to complete (up to the context deadline). The underlying JetStream consumer is NOT deleted; it will be garbage-collected by the server after InactiveThreshold.

Stop is terminal: after Stop returns, any subsequent call to Static.Start returns ErrConsumerStopped. Create a new instance to consume again.

If the consumer is stopped while still registered with a running manager, the manager will retry the failed update indefinitely (by design). Stop the manager first, or deregister this consumer before calling Stop.

Stop is idempotent; calling it multiple times is safe.

Parameters:

  • ctx: Context with shutdown deadline. If the deadline expires, Stop returns context.DeadlineExceeded but the consumer will still eventually stop.

Returns:

  • error: Context error if the wait times out; nil otherwise.

func (*Static) Subject

func (s *Static) Subject() string

Subject returns the NATS subject this consumer subscribes to.

The subject is derived from the SubjectPattern with the partition placeholder replaced by the actual partition index. If the pattern contains {{key}}, it is replaced with a wildcard (*) for subscription.

Returns:

  • string: The filter subject, e.g., "events.*.0" or "orders.2.>".

type StaticConfig

type StaticConfig struct {
	CommonConfig

	// StreamName is the JetStream stream to consume from.
	// Required.
	StreamName string `validate:"required"`

	// NumPartitions is the total number of partitions for the stream.
	//
	// This defines the sharding factor. Messages are consistently hashed
	// to a partition index in the range [0, NumPartitions-1].
	// Must be > 0.
	//
	// WARNING: Changing NumPartitions changes the hash mapping.
	NumPartitions int `validate:"required,gt=0"`

	// Partition is the specific partition index this consumer should process.
	//
	// Must be in the range [0, NumPartitions-1].
	// Typically assigned based on the application instance's ordinal (e.g., StatefulSet index).
	Partition int `validate:"gte=0"`

	// SubjectPattern is the subject template with placeholders.
	//
	// Placeholders:
	//   - {{partition}} - Replaced with partition index (0 to N-1). Required.
	//   - {{key}}       - Replaced with the partition key. Optional.
	//
	// Placeholders must occupy a full token between dots. Embedded placeholders
	// like "events.{{partition}}-v1" are invalid.
	//
	// Examples:
	//   - "events.completed.{{partition}}"       → "events.completed.0"
	//   - "events.{{key}}.{{partition}}"         → "events.tool-abc.3"
	//   - "orders.{{partition}}.{{key}}.created" → "orders.2.customer-xyz.created"
	//
	// Validation:
	//   - Must contain {{partition}} placeholder
	//   - Must not produce empty NATS subject tokens (e.g., "events..{{partition}}" is invalid)
	SubjectPattern string `validate:"required"`

	// ConsumerName is the durable consumer name.
	//
	// Must be unique per partition.
	// The final durable name on the server often incorporates the partition index
	// to avoid collisions between partitions, or the user must ensure uniqueness.
	ConsumerName string `validate:"required"`

	// HashSeed is an optional seed for the consistent hashing algorithm.
	//
	// Using a consistent seed ensures that the same message key always maps to
	// the same partition index across restarts/redeployments.
	HashSeed uint64

	// RecoveryStrategy defines how a recreated consumer resumes after an unexpected deletion.
	//
	// All strategies are supported. [RecoverFromLastProcessed] works with both
	// ManualAck=false (checkpoint advances automatically) and ManualAck=true
	// (checkpoint advances when the handler calls msg.Ack() or msg.DoubleAck()).
	//
	// # WorkQueuePolicy streams
	//
	// NATS only permits [jetstream.DeliverAllPolicy] on WorkQueuePolicy streams.
	// [RecoverFromNew] and [RecoverFromLastProcessed] are incompatible and will
	// cause [Static.Start] to return [ErrInvalidConfig]. Use [RecoverFromBeginning]
	// or [RecoveryDisabled] for WorkQueuePolicy streams.
	RecoveryStrategy RecoveryStrategy
}

StaticConfig configures a Static consumer.

func (*StaticConfig) SetDefaults

func (c *StaticConfig) SetDefaults() error

SetDefaults applies default values to the configuration.

func (*StaticConfig) Validate

func (c *StaticConfig) Validate() error

Validate checks configuration constraints.

type StaticOption

type StaticOption interface {
	// contains filtered or unexported methods
}

StaticOption applies only to Static consumers.

This interface allows both universal Options and Static-specific options (like WithHashSeed) to be passed to NewStatic.

func WithDispatchByKey

func WithDispatchByKey() StaticOption

WithDispatchByKey enables per-key concurrent message processing.

When enabled, messages are routed to separate goroutines based on their key. Messages with the same key are processed sequentially (preserving order), while different keys are processed concurrently in parallel goroutines.

IMPORTANT: SubjectPattern MUST contain {{key}} placeholder when DispatchByKey is enabled. The key is extracted based on the {{key}} position in the pattern. For example, with pattern "events.{{partition}}.{{key}}" and subject "events.0.customer-abc", the key is "customer-abc".

Placeholders must occupy a full token between dots. Embedded placeholders like "events.{{key}}-v1.{{partition}}" are invalid.

WARNING: This creates an UNBOUNDED number of goroutines - one goroutine per unique key. If your workload has millions of unique keys, memory usage will grow proportionally. Goroutines are cleaned up after KeyIdleTimeout of inactivity.

Use this when:

  • You need per-key ordering but want parallelism across keys
  • Your key cardinality is bounded (e.g., thousands, not millions)
  • Slow processing of one key should not block other keys

func WithHashSeed

func WithHashSeed(seed uint64) StaticOption

WithHashSeed sets the consistent hashing seed.

func WithKeyChannelBuffer

func WithKeyChannelBuffer(size int) StaticOption

WithKeyChannelBuffer sets the buffer size for each key's message channel.

When the buffer is full, the main pull loop blocks (backpressure). Larger buffers absorb bursts but use more memory per active key.

Only used when DispatchByKey is enabled. Default: 32

func WithKeyExtractor

func WithKeyExtractor(fn func(msg jetstream.Msg) string) StaticOption

WithKeyExtractor sets a custom key extraction function.

The extracted key determines which goroutine processes the message. Messages with the same key are guaranteed to be processed sequentially.

If not set, uses a pattern-aware extractor based on the {{key}} position in SubjectPattern. For example, with pattern "events.{{partition}}.{{key}}" and subject "events.0.customer-abc", the key is "customer-abc".

Only used when DispatchByKey is enabled.

func WithKeyIdleTimeout

func WithKeyIdleTimeout(d time.Duration) StaticOption

WithKeyIdleTimeout sets how long an idle key goroutine waits before exiting.

After this duration with no messages, the goroutine exits and is removed. A new goroutine is created if messages for that key arrive later.

Only used when DispatchByKey is enabled. Default: 30s

type WIPConfig

type WIPConfig struct {
	// Interval is the heartbeat interval for msg.InProgress() calls.
	//
	// Recommended: set Interval to AckWait/3 for safety margin.
	// Values <= 0 disable heartbeats (returns unwrapped handler).
	// Values < DefaultWIPMinInterval are clamped to prevent overhead.
	Interval time.Duration

	// MinInterval overrides the minimum interval threshold.
	// If zero, DefaultWIPMinInterval (100ms) is used.
	// Set to a negative value to disable clamping (not recommended).
	MinInterval time.Duration

	// Logger receives heartbeat errors. If nil, errors are silently ignored.
	Logger types.Logger
}

WIPConfig configures the work-in-progress heartbeat wrapper.

The wrapper periodically calls msg.InProgress() while the handler is running to extend the JetStream AckWait deadline for long-running processing.

Interval Selection Guidelines

Choose Interval based on your AckWait setting:

  • Recommended: AckWait / 3 (provides safety margin)
  • Maximum safe: AckWait / 2 (minimum margin)
  • Example: AckWait=30s → Interval=10s

Very small intervals (< 100ms) are clamped to DefaultWIPMinInterval to prevent excessive msg.InProgress() calls which can degrade performance.

Performance Characteristics

The wrapper uses lazy initialization:

  • Fast handlers (< Interval): Only a timer allocation, no goroutine spawned
  • Slow handlers (>= Interval): One goroutine per message during processing

Compatibility

WIPHandler works with all consumer types (Queue, Static, Dynamic, Broadcast) and both auto-ack and manual-ack modes. In manual-ack mode, the handler must still call msg.Ack/Nak/Term exactly once; the wrapper only calls InProgress().

type WIPHandler

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

WIPHandler wraps a MessageHandler with automatic msg.InProgress() heartbeats.

The heartbeat starts lazily: if the handler finishes before Interval elapses, no heartbeat goroutine is started. This ensures minimal overhead for fast handlers.

Thread Safety: WIPHandler is safe for concurrent use. Each Handle call operates independently with its own heartbeat goroutine (if needed).

func (*WIPHandler) Handle

func (w *WIPHandler) Handle(ctx context.Context, msg jetstream.Msg) error

Handle processes a message while extending its AckWait deadline with heartbeats.

Parameters:

  • ctx: Request-scoped context for cancellation and deadlines
  • msg: The JetStream message to process

Returns:

  • error: Any error returned by the wrapped handler

Jump to

Keyboard shortcuts

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