Docs / Upgrading from v1
Upgrading from v1
What changed in v2: /v2 import paths, error-returning handlers, and the shared consumer runtime
This page is rendered from UPGRADING.md in the repository.
v2 is a breaking release. The import paths, the handler contract, and several transport behaviors changed. This guide covers everything a v1 consumer needs.
Import paths
Every module gained the Go-required /v2 suffix:
| v1 | v2 |
|---|---|
go.digitalxero.dev/mqutils | go.digitalxero.dev/mqutils/v2 |
go.digitalxero.dev/mqutils/types | go.digitalxero.dev/mqutils/v2/types |
| — | go.digitalxero.dev/mqutils/v2/runner (new) |
go.digitalxero.dev/mq-amqp | go.digitalxero.dev/mq-amqp/v2 |
go.digitalxero.dev/mq-kafka | go.digitalxero.dev/mq-kafka/v2 |
go.digitalxero.dev/mq-nats | go.digitalxero.dev/mq-nats/v2 |
go.digitalxero.dev/mq-aws | go.digitalxero.dev/mq-aws/v2 |
go.digitalxero.dev/mq-gcp | go.digitalxero.dev/mq-gcp/v2 |
go.digitalxero.dev/mq-redis | go.digitalxero.dev/mq-redis/v2 |
Handlers return an error (the headline change)
// v1: handlers acked/nacked manually and returned nothing.
func handler(ctx context.Context, msg types.Message) {
if err := process(ctx, msg); err != nil {
_ = msg.Nack()
return
}
_ = msg.Ack()
}
// v2: return nil to acknowledge, an error to reject. The shared consumer
// runtime settles the message (with retry) based on the return value.
func handler(ctx context.Context, msg types.Message) error {
return process(ctx, msg)
}- Manual
msg.Ack()/msg.Nack()inside a handler still works; the runtime treats the duplicate settlement (types.ErrAlreadyAcknowledged) as success. - A handler that returns nil without acking is now acknowledged by the runtime; return an error when processing did not succeed.
- Batch handlers (
BatchHandlerFunc) follow the same contract: nil settles the whole batch, an error rejects every message the handler did not settle itself. - Double ack/nack on a message now uniformly returns
types.ErrAlreadyAcknowledgedon every backend (some previously returned nil).
Behavior changes to plan for
- Connection loss with
auto_reconnect: falsereturns an error fromRuninstead of idling forever. Enableauto_reconnectif you relied on the old hang. - Kafka has real at-least-once semantics. Acks commit offsets (the old
transport committed everything on receipt regardless of handler outcome).
Nacks republish to
retry_topicwith aretry_countheader and land ondead_letter_topiconceretry_max_retriesis exhausted; without aretry_topicfailures are retried locally with backoff. Handlers must be idempotent — rebalances can redeliver in-flight messages. - Kafka
skip_verifyno longer disables TLS. TLS turns on viakafkas://ortls_enabled: true;skip_verifyonly relaxes certificate verification. - NATS no longer drops messages under backpressure — delivery blocks (context-aware) when the internal channel is full, matching every other backend.
- Batch processing works on every backend (
enable_batch_processing,batch_size,batch_timeout), not just AMQP.batch_timeoutis a real duration string ("250ms"); the v1 AMQP implementation misapplied it by a factor of a million and never flushed partial batches. - Previously dead configuration is now live. If you set any of these in
v1 they silently did nothing and WILL take effect after upgrading:
AMQP
channel_pool_size/mandatory/producerdurable+auto_delete; NATSmax_reconnects/reconnect_wait/queue_group/consumer_name; SQSdead_letter_queue_url(applies a RedrivePolicy), consumer tuning and FIFO group/dedup fields; GCPdead_letter_topic+max_delivery_attempts(DeadLetterPolicy — requires IAM grants to the Pub/Sub service account), publish batching thresholds, subscription tuning; Redis auth/pool/timeout fields, stream trimming, andclaim_idle_time/pending_message_max_age(XAutoClaim pending recovery, Redis ≥ 6.2).
Removed API
amqpproducerimmediateconfig key (RabbitMQ ≥ 3.0 rejects the flag).awsconsumermessage_retention_days(was never applied; honoring it would clobber externally provisioned retention).- GCP
skip_verifyconfig keys (meaningless for the gRPC client; the emulator path usesPUBSUB_EMULATOR_HOST). - Exported
AcknowledgeMessage/NacknowledgeMessagehelpers in the kafka and nats packages — userunner.AckWithRetry/runner.NackWithRetry. Transport.DeclareExchange/Transport.BindQueuenow take acontext.Context, andBindQueue’s parameters are exchange-first with adurableflag (matching what every implementation always did).
New in v2 (non-breaking once you’re on the new paths)
- Typed builders:
mqutils.NewConsumerBuilder()/NewProducerBuilder()configure consumers without viper and take handler funcs directly. - Canonical config keys accepted by every backend alongside native keys:
destination,max_retries,consumer_group. - Symmetric URL schemes: consumers and producers register identical
scheme sets (
sqss://,gcp://,redisstreams://, … all work on both sides now). runnerpackage: the shared consumer loop (ack-retry helpers, batching, graceful shutdown, reconnect backoff, retry-budget drops with a dead-letter hook) is exported for building custom backends.- AMQP blocked-connection handling: when RabbitMQ raises a memory/disk
alarm (
connection.blocked), fire-and-forget publishes are accepted into a bounded internal queue (publish_queue_size, default 1000; overflow returnstypes.ErrPublishQueueFull) and flushed in order on unblock — v1 wrote into the stalled socket and hung. New publisher-confirms mode (publisher_confirms: true,confirm_timeout): every publish waits for the broker’s ack (types.ErrPublishNackedon nack) and publishes during a block are rejected immediately withtypes.ErrConnectionBlockedfor the caller to handle. NATS gainsreconnect_buf_sizeto bound the client buffer that already queues publishes while disconnected.
Reliability update for existing v2 deployments
RabbitMQ queue expiration is opt-in
transient_queue_expires is now disabled when omitted or zero. A positive
integer enables automatic x-expires in milliseconds for transient queues
that RabbitMQ 4.3+ requires the library to declare durable. The durability
workaround remains in effect. Intentionally durable queues do not acquire
an expiration automatically.
Existing queues without x-expires can be reused with the default setting
without recreating them, provided all other declaration arguments still match.
For queues created by the previous 30-minute default, retain their argument
explicitly:
transient: true
transient_queue_expires: 1800000Queue arguments must match on redeclaration. Removing or changing an existing
x-expires can still cause PRECONDITION_FAILED; the library never deletes
queues to resolve this. With automatic expiry disabled, upgraded durable
queues can remain after a consumer stops. Use an explicitly chosen expiry or
an operator-managed lifecycle when cleanup is required.
Retry queues have a separate lifecycle because they have no consumers:
publishing into them does not renew RabbitMQ’s inactivity lease. They no longer
inherit transient_queue_expires. If a retry queue was already created with
expiry, retain its matching argument using the new setting:
auto_declare: true
retry_queue_expires: 1800000Positive retry_queue_expires enables periodic lease renewal for that retry
queue while its consumer session is active. It requires automatic declaration;
renewal stops with the session and failures are reported through its connection
error path. Omitted/zero leaves retry expiry unset. These settings accept
positive millisecond values or zero; invalid/overflowing values fail validation.
Omitting retry_queue_ttl now applies its documented 1000 ms message retry
delay. Previously such a retry queue could trap messages indefinitely. An
existing retry queue that lacks x-message-ttl still requires a deliberate
broker/configuration migration; this update does not recreate it. Large valid
TTLs are encoded without narrowing to a 32-bit integer.
Handler concurrency and shutdown
All consumers default max_concurrent_handlers to the effective
message_channel_buffer value, independent of whether graceful shutdown is
enabled. An omitted or zero buffer resolves to its existing default of 10;
a configured buffer of 100 therefore permits up to 100 active handlers or
batches without a separate concurrency setting. Set an explicit positive
limit to override the buffer-derived default, for example:
message_channel_buffer: 100
max_concurrent_handlers: 8
enable_graceful_shutdown: true
graceful_shutdown_timeout: 30The typed consumer builder’s WithPrefetch(100) also supplies the buffer used
for this default; WithConcurrency(8) overrides it. Each batch counts as one
active unit. Explicit zero or negative concurrency is invalid. GCP accepts
concurrency up to 10000, matching its buffer ceiling, and the setting bounds
actual handler work rather than the number of StreamingPull streams.
The standalone shared runner has no buffer configuration and retains its own
default of one active handler; backend consumers pass their resolved limit
through WithConcurrency.
The graceful timeout applies to cancellation, deliberate transport closure, and terminal errors. Handlers retain a live context during the drain; that context is canceled when the drain completes or expires. Code that ignores context cancellation cannot be forcibly stopped by Go. Without graceful shutdown, handler contexts are canceled immediately; releasing a partial unhandled batch has a separate bounded one-second cleanup budget.
Delivery and configuration corrections
- Kafka commits only contiguous settled offsets within each partition. Failed
records without a retry topic are retried locally with backoff; they cannot
be skipped by a later record’s acknowledgment. A terminal acknowledgment or
dead-letter publishing failure returns an error from
Run, cancels the subscription, and leaves unresolved originals for recovery. Restart after resolving the failure; handlers must remain idempotent. - NATS JetStream
max_deliver: 1permits the first attempt. Its budget counts total deliveries, including the initial one. Authenticated NATS URLs retain their credentials when passed to the client. - Explicit Redis
stream_max_len: 0disables trimming andpending_message_max_age_seconds: 0disables stale-pending drops. Omitted values retain their defaults. Pending recovery pages past locally active entries so later abandoned records can be recovered. - GCP startup preserves existing subscription settings when they are omitted. Explicit changes are applied selectively and failures are returned. GCP’s default delivery/retry budget is five; the older documentation’s value of 50 was incorrect. SDK receive callbacks remain active through settlement, and publisher handles are reused and stopped during cleanup.
- Typed builder values override both canonical and native keys from
WithConfig. Builds snapshot evaluated configuration and no longer modify the caller’s Viper instance. Direct Viper construction retains native-key precedence when both native and canonical keys are supplied. - Unknown-backend factory errors and logs remove URL credentials and query data. Applications should avoid independently logging raw connection URLs.
Explicit producer cleanup
types.Publisher now embeds io.Closer; types.Producer inherits Close() error.
Call producer.Close() directly without a type assertion. Custom publisher and
producer implementations must provide Close() error. This update remains in
the v2 release line. Close producers when their owning component stops. GCP aborts unconfirmed work by closing its RPC clients, then stops
cached topic publishers; publishes that already returned confirmed success
remain complete. Close can interrupt pending requests with an error, and a
failed/canceled publish may already have reached the broker. AMQP releases
the producer’s own resources while retaining shared connection-pool ownership.
Kafka’s synchronous client cannot cancel an individual in-flight send. Canceling
an active publish retires that connection, interrupts its sockets, and returns
an error indicating unknown delivery. Call Start again before publishing
more messages; an already-canceled context supplied before a send leaves the
connection usable. Closing a producer also interrupts stalled sends. NATS
publish and request/reply operations honor caller cancellation and transport
closure without requiring a connection restart after ordinary caller cancellation.
producer, err := mqutils.NewProducer(ctx, config)
if err != nil {
return err
}
defer producer.Close()
if err := producer.Start(ctx); err != nil {
return err
}Kafka SASL and transactions
SASL is opt-in through sasl_enabled, sasl_mechanism, sasl_username, and
sasl_password. PLAIN and both SCRAM-SHA-256/SCRAM-SHA-512 mechanisms require TLS
unless the explicit development-only sasl_allow_insecure override is enabled.
Existing unauthenticated configurations are unchanged. Credentials also apply to
transport-owned retry/dead-letter and administrative clients.
Atomic producer writes use kafka.NewTransactionalProducerBuilder, whose Build
connects the producer. The ordinary producer rejects transaction_id and
transaction_timeout; use the transactional API when those settings are present.
Only the callback-scoped publisher can send inside InTransaction. Scoped Close
aborts without closing the parent; callbacks returning nil after Close still
receive ErrTransactionClosed. A retained scoped publisher cannot affect a later
transaction. Never automatically replay ErrTransactionOutcomeUnknown: reconcile
the earlier outcome first. Failed/fenced/uncertain producers require explicit
restart or replacement.
Consumer isolation_level defaults to read_uncommitted; opt into
read_committed to exclude aborted records. Producer transactions do not include
consumer-offset commits, retry-topic writes from consumption, or external database
side effects. See the Kafka guide.
RabbitMQ Stream backend and import availability
The new modules are go.digitalxero.dev/mq-rmqstream/v2 and the optional
go.digitalxero.dev/mq-rmqstream-dedupredis/v2. They require Go 1.25 and keep /v2
module paths. The optional adapter does not add Redis to the core Stream module.
Their vanity mappings are prepared on the supporting
go-pkgs branch,
which must be delivered before the new imports resolve publicly; then the new
mqutils modules must be released. Existing backend import paths remain available.
Use the canonical-checkout/local-replacement instructions
to evaluate the changes before those prerequisites are complete.
Native rabbitmq-stream:// and rabbitmq-stream+tls:// endpoints are independent
of AMQP. Stream Ack stores progress; it does not delete a record. Retention and
reconnect can cause replay. Durable consumer identity is tied to its filter
contract, recorded in broker metadata topology even when application filtering is
not enabled. Consumers therefore require topology declaration/discovery permission;
keep metadata until retiring the identity. Change consumer identity when changing
the filter contract.
Broker sequence deduplication requires exclusive application ownership of each producer-name/physical-stream pair and a durable publication ledger. Preserve the original sequence, partition, and immutable event on replay. Application MessageId deduplication requires an injected atomic claim store and explicit retention. Expired claims are fenced by ownership token, manual Ack waits for durable completion, and store failures leave unresolved source work replayable. Redis restart durability depends on persistence configuration. A transactional inbox is still required to make database effects atomic with their completion record. The Stream guide and runnable outbox/inbox example explain the boundaries.
GCP Pub/Sub schema enforcement was assessed but no provisioning or encoding API was added. Existing raw-byte publication can use a topic whose Avro/Protobuf schema is configured externally; Google enforces it at publish time. There is no new local validation, schema administration, or general JSON Schema support.
mqutils