Repository navigation
10.8.0
#4548
Replies: 1 comment 2 replies
|
I'm sorry, but browsing through the changelog I've noticed some changes that went into 10.8.0 that aren't in these notes yet. Most of them change behavior on upgrade, so are probably worth adding. I've prepared the list using Claude Code: Azure Service Bus
Core
|
2 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Stability Release
PLEASE NOTE THAT THIS HAS SIGNIFICANT CHANGE, DUE TO ALIGNING BEHAVIORS ACROSS OUR BROKER FLEET. YOU SHOULD READ THESE NOTES, AND TEST THOROUGHLY.
AWS SQS, GCP Pub/Sub and RocketMQ:
requeueCountnow runs down (#4341, spec 0037)On these transports the broker re-serves its own stored copy of a requeued message, and Brighter never
rewrote that copy, so every redelivery presented the same
HandledCount. A subscription'srequeueCounttherefore never ran down. A message that kept failing was requeued for ever, neverdead-lettered by Brighter. The consumers now read the broker's own delivery counter on receive
(SQS
ApproximateReceiveCount, Pub/Subdelivery_attempt, RocketMQDeliveryAttempt) and present itso that a first delivery reads
0. The message pump is unchanged and still decides when the budget isspent. No broker call is added. See ADR 0077.
All three counters are approximate, so a budget of
Rrejects on or before deliveryR, and thev3 and v4 SQS packages may reject on different deliveries.
GCP needs a
DeadLetterPolicy. Pub/Sub only populatesdelivery_attempton a subscription thathas one. Without it,
requeueCountstill cannot run down, and Brighter warns (see "New Warnings"below).
Behaviour change: the broker's count overrides a producer-set
HandledCountWhere a broker counter is available (SQS, GCP with a
DeadLetterPolicy, RocketMQ), theHandledCountcarried in the message header is now ignored on delivery from the source channel. Aproducer that sends
HandledCount = Nsees0on the first delivery. The header count no longercarries earlier budget spend into a new delivery.
The exception is a message Brighter routed to a dead-letter or invalid-message destination. A message
whose bag carries the
rejectionReasonkey keeps theHandledCountstamped on it at rejection. Adead-letter read therefore shows how many deliveries the message took, not the dead-letter queue's own
counter.
Behaviour change: a null-reason
RejectstampsrejectionReason = "None"On AWS SQS (both packages), GCP Pub/Sub and RocketMQ, a
Reject(message, null)now stampsrejectionReason = "None"on the routed copy. Before, the key was left off.rejectionMessagestaysabsent. Any code that reads dead-lettered messages and tests for the presence of
rejectionReasonwill now find it on these copies. The other Brighter-managed transports are unchanged.
The rejection-metadata keys are reserved for Brighter
rejectionReason,rejectionMessage,originalTopic,originalMessageTypeandrejectionTimestampare reserved for Brighter's use. They are now named once inParamore.Brighter.RejectionMetadataKeyNames. Don't set them on an ordinary message. A messagethat carries
rejectionReasonis treated as a routed copy: its stamped count is kept, and on atransport above it can be rejected on its first delivery.
Replaying dead-lettered messages: strip the rejection metadata
A tool that puts a dead-lettered message back on its source (a DLQ redrive, a replay script, a manual
move) must remove the rejection metadata above, and may reset
HandledCountto0. If it leavesrejectionReasonin place, the message is still treated as a routed copy. Its stamped count is atleast the budget that dead-lettered it, so the pump rejects it again on its first delivery whenever
the source's budget is no larger than that stamped count plus one. If the source's budget is larger,
the count stays where it was, and only a native broker limit bounds the message.
RocketMQ: header-owned properties win over same-named bag entries
When it publishes, the RocketMQ producer now skips any
Header.Bagentry whose key it has alreadywritten from the header:
HandledCount,MessageId,Topic,MessageType,TimeStamp,Source,SpecVersionand the other header-owned properties. Before, the bag was written last, so a stalebag entry overwrote the header value (RocketMQ's
AddPropertyis last-write-wins). That is how adead-letter copy lost its stamped
HandledCount. If you forward a received message, or set one ofthese keys in the bag to override a header, the header value now wins on every publish.
New Warnings
Three startup validation rules (reported by
ValidatePipelines, at Warning, once per subscription):requeueCountof0, or below-1, rejects the first deferral. The Warningasks whether you meant
-1(requeue for ever) or1(reject after one delivery).requeueCountmeets or exceeds a nativelimit Brighter can see (SQS
RedrivePolicy.maxReceiveCount, GCPDeadLetterPolicy.MaxDeliveryAttempts), the native limit fires first. The Warning names both values.A policy configured outside Brighter is invisible to it and isn't warned about.
DeadLetterPolicy. Thesame Warning is also logged once when the channel is created:
Subscription '…' has requeueCount … but the delivery count cannot advance: …. The budget will not run down.requeueCount: -1(the default) disables the budget and none of these fire.GCP Pub/Sub:
Rejectroutes to a dead-letter and an invalid-message topic (#4341, spec 0037)GCP
Rejectused to acknowledge the message and discard it.GcpPubSubSubscriptionnow takesdeadLetterRoutingKeyandinvalidMessageRoutingKey(new optional constructor parameters, at theend of both constructors; callers must recompile).
Rejectthen publishes a copy carrying therejection metadata, as SQS and RocketMQ do:
Unacceptablegoes to the invalid-message topic, fallingback to the dead-letter topic; every other reason, and a null reason, goes to the dead-letter topic.
The existing
DeadLetter(DeadLetterPolicy) setting is unchanged, and native dead-lettering stillworks alongside it. See ADR 0078.
Behaviour changes
Reject:GcpRejectionRouter: no destination configured for rejected message {Id} with reason {Reason}; message acknowledged without publishing.The message is still acknowledged.
promptly for another attempt, and an Error names the message id. With a native
DeadLetterPolicythat loop ends at its
MaxDeliveryAttempts. Without one, a publish that keeps failing loops for aslong as it fails.
Rejectalways returnstrueand never throws. The pull consumer'sRejectwith no receipthandle now returns
trueinstead offalse. A failed pull acknowledgement is logged instead ofrethrown, and the message stays leased until its ack deadline, so the destination may receive a
duplicate.
subscription's
MakeChannels. UnderCreateit creates the topic but not a subscription, and Pub/Subdiscards messages published to a topic nothing subscribes to. Create a subscription on each
destination topic before relying on it.
DLQ-backed channels without project IAM rights: new Warning
Creating a subscription with a
DeadLetterPolicygrants Pub/Sub's service account the forwardingroles through two IAM helpers. When the caller lacks project-level IAM rights (
PermissionDenied,Unauthenticated,Unimplementedon the emulator, or no resolvable credentials), the helper is nowabandoned with a Warning instead of failing channel creation:
{Helper} abandoned: {Rpc} on {Resource} failed with {Status}; native dead-lettering may be inactive.The subscription is still created. If you see this Warning, grant the forwarding roles yourself,
or native dead-lettering will not move messages. Any other status still fails channel creation.
RocketMQ:
RequeueandNacknow act on the broker (#4353)RocketMessageConsumer.RequeueandNacknever called the broker. A requeued or nacked message cameback only when its receive-time invisibility lease lapsed (30 s by default), and the delay passed to
Requeuewas ignored. Both now callChangeInvisibleDurationon the message:Requeue(message, delay)hides the message fordelay, so it is redelivered after about that long.A zero or missing delay redelivers it at once.
Nack(message)releases the message for redelivery at once.Behaviour changes
subscription's invisibility lease. A handler that relied on that wait as a back-off should pass an
explicit delay to
Requeue, or use aDeferMessageActionwith a delay.as zero. A delay above 12 hours is held for 12 hours and logs a Warning:
Requeue delay {RequestedDelay} for message {MessageId} is above the broker's maximum invisible duration; holding it for {MaximumDelay}.Could not change the invisible duration of message {MessageId} to {InvisibleDuration}; it will reappear when its invisibility timeout lapses,and
Requeuestill returnstrue. The message comes back when its lease lapses, as before.GCP Pub/Sub stream:
Nackreleases the message for redelivery (#4449)GcpPubSubStreamMessageConsumer.Nack/NackAsyncdid nothing, on the assumption that notacknowledging a message is enough for Pub/Sub to redeliver it. That holds for Pull, but not for the
streaming client, which keeps extending the lease on a message it is still holding. A message the pump
declined to acknowledge (
DontAckAction) was therefore never redelivered, and at the defaultBufferSize: 1it could stall the consumer for up toMaxTotalAckExtension(60 minutes by default).Nacknow releases the message, asRequeuealready did, and it is redelivered promptly.GCP Pub/Sub stream: disposing a channel no longer waits for unsettled messages (#4479)
Stopping the stream consumer waited for every message it had delivered to be acknowledged or nacked.
A message that was never settled blocked
Dispose, and with it shutdown, for up to about an hour. Theconsumer now stops with
ShutdownMode.NackImmediately. A message still held at shutdown is nacked andredelivered, and
Disposereturns promptly.GCP Pub/Sub stream: reopening a channel on the same subscription works again (#4502)
Channels on one stream subscription share a
SubscriberClient, which stops when the last channelleaves. A
SubscriberClientcannot be restarted. However, Brighter kept the stopped client cachedagainst the subscription. So when the dispatcher reopened that subscription, after
ShutthenOpenor after scaling performers to zero and back, channel creation threw
InvalidOperationException: Can only start an instance once.A second attempt then silently gave a channel that never receivedanything. A stopped client is now replaced by a new one.
A Reactor performer also disposed its stream consumer twice. With several performers on one
subscription, that could stop the shared client while the others were still reading from it, and
they then received nothing. Disposing the stream consumer is now idempotent.
Breaking change:
GcpStreamConsumer.Start()is replaced bybool TryStart(), which returnsfalseonce the consumer has stopped. OnlyGcpPubSubConsumerFactorycalled it in Brighter.GCP Pub/Sub stream: settled messages are no longer kept in memory (#4505)
For every message it delivered, the stream handler registered a callback on the
SubscriberClient'scancellation token and never removed it. That token lives as long as the client, so every message the
client delivered, payload included, stayed in memory until the channel was disposed. A long-running
stream consumer's memory grew with its throughput. Stopping the client also had to run one callback for
every message ever delivered. The callback is now removed once the message is settled.
GCP Pub/Sub:
Purgenow clears the subscription (#4508)PurgeandPurgeAsyncon a GCP channel, Pull or Stream, never worked. They purge by seeking thesubscription to a future time, but the Seek request did not name the subscription, so Pub/Sub
rejected it with
InvalidArgumentandPurgealways threw. The Seek now names the consumer'ssubscription. This also affects
CommandProcessor.Callover GCP, which purges the reply channel beforesending the request.
On a Stream subscription, a Seek clears only the messages still held by the service. The streaming
client may already have delivered some messages into Brighter's local buffer, and those were still
returned by the next
Receive. A purge now also acknowledges every buffered message published beforethe purge started. Messages published after it are kept.
Reactor: a channel disposes its message consumer only once (#4511)
When a Reactor performer stopped, its sync
Channelwas disposed twice: once by the pump when itreceived the quit message, and again when the dispatcher disposed the performer.
Channelpassed bothcalls on, so every transport's sync message consumer was disposed twice on each shutdown. For a Kafka
consumer that had created a requeue or rejection producer, the second dispose threw
ObjectDisposedExceptionfrom the already-disposed producer'sFlush.Channel.Disposeis nowidempotent, as
ChannelAsyncalready was, so the consumer is disposed once.GCP Pub/Sub: subscription and publication client configuration now adds to the connection's (#4516)
A
GcpPubSubSubscription'sstreamingConfigurationused to replace the connection'sStreamConfiguration, and aGcpPublication'sPublisherClientConfigurationused to replace theconnection's
PublisherConfiguration. Any connection-wide setting, such asEmulatorDetection, anendpoint or channel credentials, was dropped for that subscription or publication. On the emulator, the
client then went to production Pub/Sub and failed with
Unauthenticated. Now the connection'sconfiguration runs first, then the subscription's or publication's. Where both set the same builder
property, the more specific one wins. A configuration that assigns a new
Settingsobject still replaceswhatever
Settingsthe connection's configuration set.Behaviour change: the connection's configuration now also runs
If a connection sets
StreamConfigurationorPublisherConfiguration, that action now runs for everysubscription or publication, including those with their own configuration. If you repeated connection
settings in each per-entity configuration to work around the old behaviour, you can remove the
repetition. If a connection setting must not apply to one subscription or publication, override it in
that subscription's or publication's configuration.
Message ordering survives a configuration that replaces
SettingsA publisher configuration that assigned a new
PublisherClient.Settingsswitched off the messageordering an ordered
GcpPublicationasked for. Brighter sends each message with a partition key usingan ordering key, so every such send then threw
InvalidOperationException("Message ordering must beenabled…"). This also broke dead-letter and invalid-message forwarding of keyed messages under a
connection
PublisherConfigurationthat replacedSettings. After the configurations have run, Brighternow switches ordering back on for a publication with
EnableMessageOrdering = true. A publicationwithout it keeps whatever the configuration set.
Stream configurations can set
Settingsvalues directlyA stream configuration that set a value such as
builder.Settings.AckDeadlinethrewNullReferenceException, becauseSettingswas still null when the configuration ran. Brighter nowcreates
Settingsbefore running the configurations, as it already did for the publisher.GCP Pub/Sub: keyed messages can be sent through an unordered publication (#4517)
Brighter used to send every message's partition key as the Pub/Sub ordering key. The Google client
refuses an ordering key unless message ordering is enabled, so on a
GcpPublicationwithoutEnableMessageOrdering(the default), every message with a partition key failed withInvalidOperationException("Message ordering must be enabled in settings before using OrderingKey").This included the bulk send, and delayed sends when the scheduler fired them. A message mapper copies the
partition key from the request context, so a message could have a key without your code setting one.
Now Brighter sends the partition key as the ordering key only when the publisher client has message
ordering enabled. That is either because the publication sets
EnableMessageOrdering, or because apublisher configuration switched ordering on. A publisher configuration that enables ordering keeps working
as before.
The partition key now also travels as a
ce-partitionkeyattributeThe ordering key used to be the only place the partition key travelled. Each message with a partition key
now also carries a
ce-partitionkeyattribute, so a consumer receives the key whether or not thepublication is ordered. A consumer reads the attribute first. If there is none, it falls back to the
ordering key, so messages from producers on earlier versions still arrive with their key. The attribute is
not copied into
Header.Bag. A consumer on an earlier version reads the key from the ordering key asbefore, and sees the new attribute in its
Header.Bag.GcpMessageProducertakes an optionalenableMessageOrderingIf you build a
GcpMessageProduceryourself rather than throughGcpPubSubMessageProducerFactory, you canpass
enableMessageOrderingto say whether yourPublisherClientwas built with ordering enabled. If youleave it out, the producer uses the publication's
EnableMessageOrdering.Message pumps reject received messages with no handler (#4500)
ReactorandProactornow reject a received message asUnacceptablewhen it maps successfullybut runtime routing selects no handler. Previously, an event was acknowledged after an Information
log reporting zero pipelines. A command raised an exception, but the pump still acknowledged it.
The change also applies to registered routers that select no handler for a particular request.
The existing rejection policy determines the destination: an invalid-message channel or dead-letter
channel where supported and configured, or native dead-lettering on transports such as Azure Service
Bus. Without a rejection destination, the transport determines whether the message is discarded.
Subscriptions that deliberately ignore some types may therefore start sending them to a rejection
destination. To intentionally acknowledge and ignore a type, register a handler that does nothing.
Unhandled events now count toward
UnacceptableMessageLimit. Each rejection increments theunacceptable-message count; unhandled commands already incremented it. A positive limit can now stop
the pump after repeated unhandled events. Review the types handled by each subscription and its
UnacceptableMessageLimitandUnacceptableMessageLimitWindowsettings when upgrading. The defaultlimit of zero remains unlimited.
Both commands and events are logged as rejected, with their message ID and channel. The rejection
metadata identifies the request type with no handler. Commands without handlers no longer enter the
general dispatch-error logging path.
Local
Publish/PublishAsynccalls, including calls inside handlers using the received context, canstill have zero subscribers. Application-handler exception policy and command-handler cardinality
checks retain their existing behavior.
New public API:
RequestContext.RequireHandlerForNextDispatch()requires a handler for the nextimmediate
Send,SendAsync,Publish, orPublishAsynccall using that context. CommandProcessorconsumes the requirement before building pipelines. Context copies and scheduled dispatches do not
carry it. Custom
IAmACommandProcessordecorators must pass the pump's context instance through toCommandProcessor; substituting or omitting it loses the requirement and retains the previous
acknowledgement behavior for messages without handlers.
RabbitMQ: channel factories declare the queue before returning the channel (#4519)
The RabbitMQ
ChannelFactoryin bothParamore.Brighter.MessagingGateway.RMQ.AsyncandParamore.Brighter.MessagingGateway.RMQ.Syncnow honoursSubscription.MakeChannelswhen it creates achannel. Previously, the queue was declared and bound only on the channel's first
ReceiveorPurge.A message published to the exchange between creating the channel and that first receive matched no
binding, so RabbitMQ dropped it. Because Brighter publishes with
mandatory: false, nothing reportedthe loss, and with publisher confirms on the publish was still acknowledged.
Createdeclares and binds the queue, the invalid-message queue, the dead-letter queue and, wherenative delay is configured, the delayed-requeue exchange, before the factory returns.
Validatechecks that the queue exists. If it does not, the factory throwsChannelFailureException.Assumemakes no broker connection.A message published before any subscriber has created its channel is still not delivered: the
producer does not declare subscriber queues. That is ordinary publish/subscribe behaviour.
Behaviour change: provisioning failures now surface when the channel is created
The factory now connects to the broker, so connection, declaration and validation failures come out of
channel creation as
ChannelFailureException. In a Service Activator host that is dispatcherstart-up (
Dispatcher.Receive()), so the host fails to start. Previously the same failures surfaced onthe message pump's first receive, which logged them and retried every
ChannelFailureDelay.Connecting is retried when the broker is unreachable:
AmqpUriSpecification.ConnectionRetryCountattempts (default 3) with exponential back-off from
RetryWaitInMilliseconds(default 1000 ms; about14 s in total per channel), behind a circuit breaker that opens for
CircuitBreakTimeInMilliseconds.Declaring, binding and validating the queue are not retried.
The consumer also starts consuming when the channel is created rather than on the first receive, so
up to
BufferSizemessages may be prefetched, unacknowledged, before the pump runs. They are returnedto the queue if the channel is disposed or closed.
Azure Service Bus queue subscription settings (#4269)
Queues created by a consumer now honor
AzureServiceBusSubscriptionConfiguration, including sessions, delivery count, lock duration, default message lifetime and dead-lettering on expiration. Default consumer-created queues now use the subscription defaults (five deliveries, a three-day lifetime and dead-lettering on expiration) instead of the broker defaults. Existing queues and producer-created queues are unchanged.For session-enabled queues, provision the queue before producers start, or let the configured consumer create it first. Azure Service Bus does not allow sessions to be enabled on an existing queue.
Compatibility: custom
IAdministrationClientWrapperimplementations must addCreateQueueAsync(string, AzureServiceBusSubscriptionConfiguration). The original overload remains available. Calls passing a literalnullas the second argument must use theautoDeleteOnIdleparameter name to select the original overload.RabbitMQ shared connection lifetime
Disposing a RabbitMQ producer or consumer now releases only its own use of the pooled connection.
Other gateways sharing that connection can continue sending and receiving. Both RabbitMQ gateways
close the connection when its last gateway releases it, including when channel cleanup throws.
After a reset, disposing an old gateway preserves a replacement held by another gateway and closes
an unused replacement. Explicit pool reset and removal still close connections immediately.
Dispose every producer and consumer to release its connection reference. An undisposed gateway can
keep the connection open for the lifetime of the process. Consumer operations after disposal now
throw
ObjectDisposedException.Relational outbox configuration registration (#4279)
AddProducers(Action<ProducersConfiguration>, ...)now registers a relational outbox's database configuration whenIAmARelationalDatabaseConfigurationis missing.The fallback reuses the outbox's configuration instance. Existing explicit registrations and provider lifetimes remain unchanged.
A later ordinary registration overrides the fallback for single-service resolution; a later
TryAdddoes not.The deferred
AddProducers(Func<IServiceProvider, ProducersConfiguration>, ...)overload still requires explicit configuration registration when a provider needs it.Non-relational outboxes do not register database configuration.
Azure configuration options: rebuild and test when upgrading (#4285)
Six Azure configuration fields are now public read/write properties, so property-based tooling can discover them:
AzureServiceBusSubscriptionConfigurationSqlFilter,UseServiceBusQueueAzureServiceBusPublicationUseServiceBusQueue(also inherited byAzureServiceBusPublication<T>)AzureBlobLockingProviderOptionsStorageLocationFuncAzureBlobArchiveProviderOptionsStorageLocationFunc,TagsFuncNames, types, defaults, and post-construction assignment are unchanged. Property-based configuration binding now applies the Service Bus scalar options.
Delegate-valued Blob options remain configured in code; this change does not make delegates bindable from text configuration.
Rebuild and test applications and dependent libraries when upgrading. Ordinary reads, assignments, and object initializers remain source-compatible after recompilation.
Already compiled code that accesses these fields is not binary-compatible with the new properties; replacing Brighter assemblies without rebuilding is not sufficient.
Code using field reflection or passing these members by reference needs source changes. Property-based serializers may now encounter delegate values they previously ignored.
Scoped lifetime per pipeline (spec 0036, #4256)
HandlerLifetime,MapperLifetimeandTransformerLifetimenow govern a pipeline-scoped DI scope: aScopedhandler, mapper or transform resolves from one DI scope shared by everyScopedparticipant on that pipeline, and disposed when the pipeline ends. An ASP.NET Core host can additionally opt a pipeline in to adopting an ambient request scope instead of creating its own, through a newParamore.Brighter.Extensions.AspNetCorepackage (AddBrighterRequestScope(...)), andValidatePipelines()gained seven new startup checks for common lifetime and scope-registration mistakes. See docs/guides/lifetimes-and-scoping.md for the full model, decision guide and troubleshooting, and ADR 0070 through ADR 0076 for the design.This is a substantial change with fourteen breaking-change items, catalogued here in one list rather than split across each ADR that introduced one:
MapperLifetime.Scopedno longer caches for the life of the process (behavioural) — it now means one instance per pipeline, disposed when the pipeline ends, like every otherScopedlifetime. Nothing at compile time warns of this, and there is no compatibility flag. If your mapper relied on the old process-wide caching, migrate toMapperLifetime = Singleton, which states the same intent explicitly — check first that it has no container-Scopedconstructor dependency (see the captive-dependency item below). This is a different destination from actually wanting per-pipeline sharing: because the three lifetimes are validated as one joint choice onceValidatePipelines()is called, a per-pipeline-sharedScopedmapper requiresHandlerLifetime,MapperLifetimeandTransformerLifetimeset together —{Scoped, Scoped, Transient}is not a valid destination.IAmAMessageMapperFactory,IAmAMessageMapperFactoryAsync,IAmAMessageTransformerFactory,IAmAMessageTransformerFactoryAsync,IAmAMessageMapperRegistryandIAmAMessageMapperRegistryAsyncall gainIAmAScope? CreatePipelineScope(), and their creation methods gain a scope parameter (source and binary) —Create(Type, IAmAScope? scope = null)on the four factories,Get<T>/GetAsync<T>on the two registries. A hand-rolled implementation of any of these six must addCreatePipelineScope()(returningnullis legitimate for a factory or registry with no container to scope) and accept the new scope parameter, even if it ignores it.Scopedmapper or transformer factory no longer caches at factory level (behavioural) — callingCreate(type)directly, outside a pipeline scope, now resolves a fresh instance on every call rather than returning one cached for the process.IAmAScope?parameter (binary) —WrapPipeline,UnwrapPipeline,WrapPipelineAsync,UnwrapPipelineAsync, and the two abstract basesTransformPipeline/TransformPipelineAsync. Source-compatible for a caller that recompiles; binary-breaking for one that does not.ErrorasFailedToDisposePipelineScopeorFailedToDisposePipelineScopeAfterFailedBuild, then swallowed one layer up exactly as before. Only an operator's log output changes; no exception reaches a caller that did not already see one.IAmAHandlerFactorygainsCreatePipelineScope(), andIAmALifetimegainsPipelineScope(source and binary) — bringing the total to eight broken interfaces across the two ADRs behind this and the previous item, three of which are not mapper/transform factories at all: the two mapper registries above, andIAmALifetime.IAmALifetimealso gainsIAsyncDisposable(source and binary) — so the handler pipeline scope can be disposed asynchronously rather than blocking a thread on an async-only DI scope'sDisposeAsync(). A hand-rolled implementation must addDisposeAsync(); anetstandard2.0target has no default-interface-member escape hatch for this, the same constraint the other seven broken interfaces already carry.HandlerLifetimeScope.Dispose()is repaired to survive a throwing handlerRelease(behavioural) — an exception a caller catches today fromDispose()now only reaches the log afterwards, rather than propagating.ServiceProviderHandlerFactorystops keeping a DI scope of its own (behavioural) —Createnow throwsConfigurationExceptionwhen given a lifetime whosePipelineScopeisnullon a non-Singletonhandler lifetime, rather than silently falling back to a scope it owned itself.Scopedartefact cache stops publishing a faultedLazy, on the owned path as well as the borrowed one (behavioural) — a resolution failure now reaches a host that never opted in to ambient adoption, closing theScopedhalf of issue #4260 (theSingletoncache is unchanged).PipelineBuilder<TRequest>'s two public dispatch constructors gain a defaultedbool isolateSubscribers(binary) — source-compatible for a recompiling caller.IBrighterOptionsgainsDefaultScopeAffinity(source and binary) — breaking a hand-rolled implementation ofIBrighterOptions, which must add the member.IAmAPipelineValidatorand combine the results (behavioural, and source and binary) — an application that registers its ownIAmAPipelineValidatorno longer replaces Brighter's validation wholesale; both are now run and their findings combined. The source and binary half:BrighterValidationHostedService's public constructor now takesIEnumerable<IAmAPipelineValidator>in place ofIAmAPipelineValidator, so any caller constructing it directly must migrate.ServiceActivatorHostedService's constructor is unchanged — it resolves its validators insideStartAsync.ValidatePipelines()and mixesTransientwithScopedacrossHandlerLifetime,MapperLifetimeandTransformerLifetimenow fails to start (compatibility) — such a configuration runs today, with the mixed pair simply not sharing pipeline-scoped dependencies; callingValidatePipelines()against this version reports it as an error. The remedy is to pick one lifetime for all three (TransientorScoped), with any of the three optionallySingletoninstead. This break only lands for an application that opts in to validation.For each of
IAmAMessageMapperFactory,IAmAMessageMapperFactoryAsync,IAmAMessageTransformerFactory,IAmAMessageTransformerFactoryAsync,IAmAHandlerFactorySync,IAmAHandlerFactoryAsync,IAmAHandlerFactoryandIAmALifetime— what changed, and how a hand-rolled implementation migrates:IAmAMessageMapperFactory/IAmAMessageMapperFactoryAsync— gainIAmAScope? CreatePipelineScope();Creategains a trailingIAmAScope? scope = nullparameter. Migration: addCreatePipelineScope()returningnullunless the factory has a container to scope; ignore the newscopeparameter unless the factory means to resolve from it.IAmAMessageTransformerFactory/IAmAMessageTransformerFactoryAsync— the same two additions, onCreatefor a transformer type. Same migration.IAmAHandlerFactorySync/IAmAHandlerFactoryAsync— inheritIAmAHandlerFactory's newCreatePipelineScope()member (below); their ownCreate/Releasesignatures are unchanged. Migration: a type implementing either interface directly must addCreatePipelineScope(), returningnullunless it has a container to scope.IAmAHandlerFactory— gainsIAmAScope? CreatePipelineScope(), creating a DI scope for one handler pipeline to resolve from. Migration: implement it, returningnullfor a factory with nothing to scope.IAmALifetime— gainsIAmAScope? PipelineScope { get; }, carrying the handler pipeline's own scope handle (distinct from this interface's existing job of tracking handler instances so they can be released). Migration: implement the property, returningnullif the implementation has no pipeline scope of its own. Also gainsIAsyncDisposable, soCommandProcessor's async send/publish paths canawait usingthe pipeline scope instead of blocking on a synchronousDispose(). Migration:public ValueTask DisposeAsync() => PipelineScope?.DisposeAsync() ?? default;.Validate subscription channel factory compatibility (spec 0037, #4334)
Brighter now validates, at
ValidatePipelines()time, that every subscription's declaredChannelFactoryTypeis compatible with the channel factory it will actually be handed at startup —turning "compiles, then dies deep in Dispatcher start" into a named,
ValidationSeverity.Errorstartup finding. Landing the rule also corrected five transports whose
ChannelFactoryTypewaswrong or missing: GCP Pub/Sub and MQTT previously declared a consumer factory rather than their
own, and AWS SQS, AWS SQS V4 and Postgres declared none at all — so those three transports are, for
the first time, routable through a
CombinedChannelFactory. SeeADR 0072,
ADR 0073 and
spec 0037 for full details.
Breaking change: a subscription type with no
ChannelFactoryTypeoverride now fails validationAny out-of-repo
Subscriptionsubclass that does not overrideChannelFactoryTypeinherits the baseclass's default,
InMemoryChannelFactory— previously invisible, now reported. Symptom:ValidatePipelinesreturns anErrorcitingParamore.Brighter.InMemoryChannelFactoryas thedeclared type. Remedy:
Interim workaround:
ValidatePipelines(throwOnError: false).Breaking change: MQTT via a plain
Subscription<T>now fails validationA plain
Subscription<T>used with MQTT works today, because nothing previously checked the declaredtype against the gateway it is handed. Symptom:
ValidatePipelinesreturns anErrorcitingParamore.Brighter.InMemoryChannelFactoryas the declared type againstParamore.Brighter.MessagingGateway.MQTT.ChannelFactory. Remedy: useMqttSubscription<T>.Suppressible with
ValidatePipelines(throwOnError: false).Breaking change: AWS SQS, AWS SQS V4 and Postgres subscriptions routed through an in-memory
CombinedChannelFactoryslot now fail at Dispatcher start, not just at validationBecause AWS SQS, AWS SQS V4 and Postgres subscriptions previously declared no
ChannelFactoryTypeatall, they inherited the base default,
InMemoryChannelFactory. ACombinedChannelFactorythatincluded an
InMemoryChannelFactoryinner factory therefore matched these subscriptions by exacttype and silently routed them to the in-memory bus instead of failing. The subscriptions now declare
their real factory type, so that in-memory match no longer occurs: if the
CombinedChannelFactoryhasno inner factory of the subscription's real type, routing now fails with a
ConfigurationException(
No channel factory found for subscription {name}) when theDispatcherstarts. This is caused bythe transport corrections, not by the new validation rule, so
ValidatePipelines(throwOnError: false)does NOT avoid it — it is a routing change, not a validation verdict. Remedy: add the transport's
real channel factory to the
CombinedChannelFactory, or stop relying on the in-memory route.Breaking change: a
ChannelFactoryTypeoverride returningnullnow fails validationAn out-of-repo
ChannelFactoryTypeoverride that returnsnullin a single-factory (non-combined)configuration previously went unchecked. Symptom: a startup
Errorreadingdeclares no ChannelFactoryType. Remedy: return a real channel factory type from the override. Unlike theCombinedChannelFactorycase above, this one is suppressible withValidatePipelines(throwOnError: false).Backstop handlers preserve explicit message-pump actions
DeferMessageOnError,RejectMessageOnError,DontAckOnError, and their async variants now preserve explicitDeferMessageAction,RejectMessageAction,DontAckAction, andInvalidMessageActionexceptions instead of replacing them with the backstop's configured action. A non-emptyAggregateExceptionwhose direct inner exceptions are all pump actions is also preserved for the pump to handle. Mixed, empty, or nested aggregates still use the configured fallback. Application failures, includingOperationCanceledExceptionandTaskCanceledExceptionfrom dependency timeouts, continue to use that fallback.If a pipeline has multiple backstops, the innermost backstop now determines the action for an application failure; outer backstops preserve it. For example,
[RejectMessageOnError(step: 0)]wrapping[DeferMessageOnError(step: 1)]now defers the message instead of rejecting it. Lower step numbers are outer wrappers. Review pipelines with stacked backstops if they relied on the previous outermost-backstop behavior.DynamoDB outbox: configured operation timeouts now take effect (#4434)
Both
Paramore.Brighter.Outbox.DynamoDB(AWS SDK v3) andParamore.Brighter.Outbox.DynamoDB.V4now honourDynamoDbConfiguration.Timeoutand per-call outbox timeouts. Previously, these values were ignored.Upgrade impact: the existing default of 500 ms now takes effect. Operations that previously completed after that deadline may now throw
OperationCanceledException. Review your timeout settings before upgrading, especially for batches and queries: one deadline covers the entire operation, including all batch items or query pages.To allow a longer deadline, set the configuration's
timeoutconstructor argument (in milliseconds), for example:To disable the outbox deadline, use
new DynamoDbConfiguration(timeout: 0); a configured value of-1also disables it. Caller cancellation and the AWS SDK client's own timeouts still apply.For methods accepting a per-call timeout:
-1(the default) usesDynamoDbConfiguration.Timeout; it does not independently disable the deadline.0disables the outbox deadline for that call.Cancellation is cooperative. Synchronous AWS SDK table-metadata discovery remains governed by the SDK client's timeout. Adding a message to a transaction queues the write; the later commit must be cancelled through the transaction provider. Public signatures and default parameter values are unchanged, but applications that relied on the previously ignored deadlines may need configuration changes.
AWS: configurable
MaximumMessageSizefor SNS topics and SQS queuesAmazon SNS now accepts message payloads up to 1 MiB, but only if you raise the topic's
MaximumMessageSizeattribute — the default is still 256 KiB. Brighter's send path never assumed a fixed limit, but the provisioning path had no way to set the attribute, so a topic Brighter created withOnMissingChannel.Createwas stuck at 256 KiB.SQS is the other way round. A queue created today already defaults to the full 1 MiB, so
SqsAttributes.MaximumMessageSizeis there for when you want a queue to reject anything over a size you pick, not to unlock headroom you'd otherwise be missing.SnsAttributesandSqsAttributes(both the V3 and V4 packages) gain an optionalmaximumMessageSizeconstructor parameter, in bytes:For SNS the value is applied with a
SetTopicAttributescall afterCreateTopic, because SNS rejectsCreateTopicwhen a supplied attribute differs from the existing topic's. That means the setting also raises the limit of a topic Brighter created earlier at the default size. For SQS the attribute joins theCreateQueuerequest, which already re-applies attributes to an existing queue.Before you raise a topic
AWS puts some sharp edges around a topic above 256 KiB, and they are worth reading before you turn this on:
MaximumMessageSize— a 500 KiB message on a 1 MiB topic arrives intact on a queue still sitting at 256 KiB. A directSqsMessageProducersend is bounded by the queue's limit as normal.[Compress]and[ClaimCheck]thresholds compare the uncompressed body only. A compressed body goes out base64-encoded, which inflates it by roughly a third, so leave yourself headroom under the topic's maximum.Replay Outbox Messages on Inbox Duplicate (spec 0027)
When an inbox detects a duplicate request, Brighter can now optionally replay the outbox messages that were produced under that request's causation, rather than silently dropping the duplicate. The feature is opt-in (
OnceOnlyAction.Replayon the inbox attribute) and non-breaking: it requires a causation-tracking inbox and outbox (IAmACausationTrackingInbox/IAmACausationTrackingOutbox), and the relational stores gate the causation-aware write on a memoized column probe so un-migrated schemas keep depositing unchanged. Startup pipeline validation fails fast if aReplaypipeline is configured without causation tracking. See ADR 0057 and spec 0027 for full details.Usage requirement: thread your
RequestContextthroughPost/DepositPostReplay links a duplicate back to its original outbox messages through the causation id.
[UseInbox]stamps that id into the pipeline'sRequestContext.Bag, and the outboxAddreads it back from the bag — but only if the handler that deposits the outbox message passes its ownContextdown. If a handler calls the idiomatic_commandProcessor.Post(evt)(orDepositPost) without a request context,CommandProcessorcreates a fresh context, the causation id is lost, the message is stored with anullCausationId, and a laterReplayfinds nothing to replay — a silent no-op with no error.To use Replay, every handler that produces outbox messages must forward its handler
Context:This applies to the async equivalents (
PostAsync/DepositPostAsync) as well.Source-breaking change:
IRequestContext.InstrumentationOptionsIRequestContextgains a new requiredInstrumentationOptions InstrumentationOptions { get; set; }member. It carries the instrumentation options configured for the pipeline that created the context, so middleware handlers can gate their own telemetry (for example onInstrumentationOptions.Brighter) without taking a dependency on how the processor was configured.Paramore.Brightertargetsnetstandard2.0, which does not support default interface members, so — as with the spec-0027/0029 box-provisioning interface additions — this is exposed as a plain abstract member.The change is source-breaking for any third-party or test type that implements
IRequestContext: such types will fail to compile until they add the new member. It affects more consumers than just those adopting Replay, hence its call-out here. The shippedRequestContextalready implements it and defaults toInstrumentationOptions.All, so call sites that use the shipped context require no change.Kafka: classify or suppress the log level of consumer error-callback events (#4264)
librdkafkareports transport conditions — a broker briefly unreachable, a coordinator failover, a metadatarefresh — through the consumer's error callback. Brighter logged every one of them at a fixed level: fatal at
Error, everything else atWarning. A busy or lossy network therefore produces a stream ofWarningentries for conditions the client recovers from unaided, and the only way to quieten them was to turn the level
down for the whole gateway — losing the warnings that did matter.
KafkaSubscriptionnow takes an optionalErrorLogLevelhook —Func<Error, LogLevel>?, settable through theconstructor or as a property:
The hook is passed each error the consumer receives and returns the level to log it at; returning
LogLevel.Nonesuppresses the entry entirely. When no hook is supplied the previous behaviour is preservedexactly.
The hook affects logging only. Fatal handling is unchanged and
Error.IsFatalremains authoritative: afatal error latches the consumer as unrecoverable whatever level the hook returns, so suppressing a fatal
error's log entry does not suppress the error.
One internal consequence: because the level is now chosen per error, the two fixed-level
[LoggerMessage]sources for consumer errors are replaced by a single
ILogger.Logcall. The message template and its namedplaceholders (
{ErrorCode},{ErrorMessage},{FatalError}) are unchanged, so structured-logging queriesover those fields keep working.
Kafka: message timestamps no longer lose their time zone (#4283, closes #4253)
KafkaDefaultMessageHeaderBuilderwrote the Brightertimestampheader fromHeader.TimeStamp.DateTime— theoffset-less component of a
DateTimeOffset— formatted with the invariant culture. The offset never reachedthe wire, so the reader had to guess, and
KafkaMessageCreatorguessed host-local: it parsed withDateTime.TryParse(..., AssumeUniversal)and let the result be re-anchored to the consumer's local zone. On aproducer or consumer running anywhere other than UTC,
Header.TimeStampdrifted by the host's UTC offset — anddrifted again on every hop.
The producer now writes RFC 3339 normalised to UTC (
yyyy-MM-ddTHH:mm:ss.fffZ), matching the CloudEventsce_timeheader — which already round-tripped correctly — and the legacy timestamp header written by the othergateways. The reader parses offset-aware with
DateTimeOffset.TryParseunderAssumeUniversal | AdjustToUniversal, so an offset on the wire is honoured and the result stays anchored toUTC.
The Unix-milliseconds fallback had the same defect by a different route: it truncated
DateTimeOffset.FromUnixTimeMilliseconds(...)through.DateTime, which yields aDateTimeKind.Unspecifiedvalue that converts back to a
DateTimeOffsetat the host offset. It now returns theDateTimeOffsetdirectly.
Mixed-version rollout
The wire format of the
timestampheader changes, so it is worth knowing what happens while producers andconsumers are on different versions:
AssumeUniversalcovers the legacyoffset-less format, so a message in flight from an older producer is read as UTC rather than as local time.
DateTime.TryParse, whichaccepts RFC 3339 and honours the trailing
Z, so the instant is read correctly.If you were compensating for the drift downstream — adding the host offset back onto
Header.TimeStamp, say —remove that correction when you upgrade.
Azure Service Bus: relative CloudEvents
sourceanddataschema(#4310)CloudEvents defines both
sourceanddataschemaas URI-references, which may be relative.AzureServiceBusMessageCreatorparsed them with the absolute-onlynew Uri(string)overload, so anymessage carrying a relative
sourcethrewUriFormatExceptioninside the consumer's receive loop andwas undeliverable. Azure Service Bus was the only gateway affected — every other transport already
parsed wire URIs with
Uri.TryCreate(..., UriKind.RelativeOrAbsolute, ...).Fixed on both sides. The publisher also wrote
dataschemaas aUriobject rather than a string; theService Bus SDK serialises a
Uriapplication property viaUri.AbsoluteUri, which throws for arelative URI, so publishing one failed at send time.
Behaviour change:
Header.DataSchemamay now benullon messages received over Azure Service BusPreviously, a message received over Azure Service Bus with no
dataschemaon the wire was given afabricated
http://goparamore.iovalue. That is the documented default forSource, not forDataSchema;MessageHeader.DataSchemais nullable and every other Brighter backend yieldsnull.Azure Service Bus now does too.
If you read
Header.DataSchemaoff an Azure Service Bus message without a null check, add one —you will now get
nullwhere you previously got a meaningless URI. Code that already treats theproperty as nullable (as its type declares) is unaffected. This also stops Brighter re-publishing the
invented value on every requeue.
A relative
dataschemastored in a relational Outbox is also now read back correctly:RelationDatabaseOutboxread it withUriKind.Absoluteand silently dropped it tonull,inconsistent with the
Sourcereader in the same class and with every other Outbox implementation.MQTT:
ReceiveAsyncwaits for a message, and honours the caller's cancellation token (#4240)MqttMessageConsumerbuffered arrivals in a queue and returned whatever happened to be in it at themoment of the call. An empty buffer returned immediately with a single
MT_NONEmessage, so a callerwho wanted to wait for a message had to sleep before every
Receiveand hope the sleep was longenough - the timeout argument bounded how long the drain was allowed to take, not how long to wait
for a message to arrive.
ReceiveAsyncwasTask.FromResult(Receive(timeOut)): synchronous, and itignored its
cancellationTokenentirely.Both now wait up to
timeOut(300 ms if unspecified) for at least one message to arrive, and arewoken by the arrival itself rather than by an interval expiring. A receive that finds nothing inside
the window still returns the single
MT_NONEmessage, unchanged.Behaviour change: a cancelled
ReceiveAsyncnow throwsReceiveAsyncnow observes thecancellationTokenyou pass it and throwsOperationCanceledExceptionwhen you cancel, which is the contract the interface declares and the behaviour the other async
gateways already have. Previously the token was accepted and ignored, so a cancelled receive returned
an empty result instead.
This does not affect the Brighter pump.
ProactorcallsChannel.ReceiveAsync(TimeOut)without atoken and stops on an
MT_QUITmessage, not by cancelling a receive in flight, so shutdown is a cleanstop exactly as before. The change is visible only to code calling
IAmAMessageConsumerAsync.ReceiveAsyncdirectly with a token it cancels - most often a test. If youhave such a call and relied on it returning empty, catch
OperationCanceledException. A timeoutelapsing is not cancellation and still returns
MT_NONE.BufferSizeis now honoured on MQTTA consumer built by
MqttMessageConsumerFactoryreturns at most the subscription'sBufferSizemessages per receive, which is what that setting means. Previously a receive drained the whole buffer,
so a burst larger than
BufferSizeoverflowed the BrighterChannelwrapper and threw. Anything stillbuffered is left for the next call. A directly-constructed
MqttMessageConsumerdoes not sit behind aChanneland keeps its uncapped behaviour unless you pass the new optionalbatchSizeargument.AWS SNS:
SendWithDelayhonours its delay, and needs a scheduler to do it (#4240)SnsMessageProducer.SendWithDelay(bothParamore.Brighter.MessagingGateway.AWSSQSand…AWSSQS.V4) discarded the delay it was given: the sync overload forwardedTimeSpan.Zeroto theasync implementation, so a delayed send published immediately. It now forwards the delay it was
called with, and a delayed send is handed to the configured message scheduler as the other transports
already do.
Behaviour change: a delayed send with no scheduler configured now throws
Because the delay never survived the call, the no-scheduler path could not previously be reached from
SendWithDelay— the send simply went out at once. Now that the delay is honoured, a delayed sendwith no scheduler configured throws
ConfigurationExceptionnaming the missing setting, rather thanpublishing immediately or failing with a
NullReferenceExceptionfrom inside the send.If you call
SendWithDelayon SNS and have noMessageSchedulerFactoryconfigured, that callsilently behaved as an immediate publish and will now throw. Either configure a scheduler, or call
Sendif immediate publication was what you wanted.Delayed sends now also accept either half of the scheduler pair: a host that configures only
IAmAMessageSchedulerSyncor onlyIAmAMessageSchedulerAsyncno longer fails on a cast. The callprefers the half matching the path it is on. This matches Redis, Kafka, MsSql, MQTT and the in-memory
reference implementation.
GCP Pub/Sub:
ReceiveandReceiveAsyncbound the Pull to the caller's timeout (#4240)GcpPullMessageConsumerdocumented itstimeOutas "not strictly used by the underlying GooglePub/Sub client". It is now used: the Pull is bounded to that window, so an empty subscription returns
after the requested time instead of long-polling until a message arrives. An elapsed window is
reported as a normal empty receive.
When
timeOutis null or non-positive, no deadline of ours is applied and the client's ownper-method expiration stays in force — the behaviour of the overload this replaced. In that case a
DeadlineExceededstill means a Pull took longer than the library expects, not that the subscriptionis empty, and it is logged and rethrown as before rather than being reported as an empty receive.
If you relied on
Receiveblocking until a message arrived, pass a longertimeOut, or omit itto keep the client default.
MQTT: the producer emits producer telemetry (#4240)
MqttMessageProduceremitted no producer events. It now callsBrighterTracer.WriteProducerEventonboth the sync and async send paths, which every other transport producer already did — MQTT was the
only gateway without it.
Verbosity follows the new optional
instrumentationOptionsconstructor argument, which defaults toInstrumentationOptions.AllasRmqMessageProducerandInMemoryMessageProducerdo. Note thatAllincludesInstrumentationOptions.RequestBody, so message bodies are recorded on producerspans unless you pass a narrower option. Construct the producer with, for example,
InstrumentationOptions.RequestInformationif message bodies must stay out of your traces.invalid-message producers that
MqttMessageConsumerbuilds internally do not take the argument, sothey are fixed at
Alland their spans carry message bodies with no supported way to narrow them.Tracked as #4365.
RMQ.Async: subscriptions declare durable queues by default (#4355)
RmqSubscriptionandRmqSubscription<T>inParamore.Brighter.MessagingGateway.RMQ.Asyncnow defaultisDurabletotrue. Previously they defaulted tofalse, which asked the broker for a transient,non-exclusive queue.
RabbitMQ 4.3 deprecates that combination and refuses to declare it by default:
Because
isDurable: falsewas the default, an out-of-the-box RMQ.Async consumer could not connect to a4.3 broker at all - the declare failed and Brighter surfaced a
ChannelFailureException. The deprecationnotice states the feature will be removed "regardless of the configuration", so permitting it through
broker settings is only a stopgap.
Paramore.Brighter.MessagingGateway.RMQ.Syncis unchanged and still defaults toisDurable: false. Ittargets the RabbitMQ 3.x line through the legacy
RabbitMQ.Client6.x API, and 3.x permits transientnon-exclusive queues.
Breaking: an existing transient queue will fail to redeclare
Why this default had to move at all. This is not Brighter changing its mind about a sensible default -
it is RabbitMQ changing what it supports. Transient non-exclusive queues were a supported queue shape
for the whole life of the 3.x line; 4.3 deprecates them and refuses to create them, and the deprecation
notice is explicit that they will be removed in a future major version "regardless of the configuration".
A default that a current broker will not accept is not a default we can keep. The cost of moving it is the
migration below, and there is no version of this change that avoids that cost - a broker cannot hold one
queue under two durabilities.
What goes wrong. RabbitMQ rejects a
QueueDeclarewhose arguments differ from the queue that alreadyexists, and durability is one of those arguments. If you are on RMQ.Async and your queues were created
under the old default, upgrading will fail when Brighter reopens the channel:
Brighter surfaces that as a
ChannelFailureException. It happens on the first receive, not at startup, soit can look like a runtime fault rather than an upgrade step.
Two ways out. Either keep the old behaviour explicitly:
feature, but leaves you on a queue shape RabbitMQ intends to remove -
or drain and delete the existing queue and let Brighter recreate it as durable. Deleting a queue
discards any messages still in it, so drain it first if that matters. This is the option that leaves you
on a supported queue shape.
What changes once the queue is durable. A durable queue survives a broker restart, so messages that
would previously have been discarded with the queue now outlive it. For most deployments that is the
behaviour you wanted; if you were relying on a restart to clear a backlog, you no longer get that.
Note the dead-letter queue is declared with the same durability as its subscription, so both move together
and a partially-migrated pair is not possible.
We hit this inside Brighter's own test suite while making the change: two tests pre-created their queue as
transient and then opened a subscription that now defaults to durable, and failed with exactly the error
above. If it catches the suite that introduced the change, it will catch upgrades.
MSSQL transport provisions its queue table (#4343)
OnMissingChannelon anMsSqlSubscriptionor aPublicationis now honoured by the MSSQLmessaging gateway, as it already was by PostgreSQL. It is read as a channel is opened and as a
producer is built:
MakeChannelsCreate(the default)ValidateConfigurationExceptionat startup, rather than failing on the first sendAssumeBefore this, the setting was accepted and never read:
MsSqlMessageProducerFactoryandChannelFactoryprovisioned nothing, so every MSSQL transport user ran the DDL themselves. That iswhy
MsSqlQueueBuilderis public, and it remains public and unchanged.The queue table is resolved through
SCHEMA_NAME()— the schema the login defaults to — and notthrough
RelationalDatabaseConfiguration.SchemaName.MsSqlMessageQueueemits an unqualified[{QueueStoreTable}]in every statement it issues, so it reads through the default schema too;creating the table anywhere else would put it where the gateway never looks.
SchemaNamecontinuesto apply to the Outbox and the Inbox, which do qualify their SQL.
Behaviour change:
Createis the default, so an existing application will now try to create its queue tableIf your queue table is provisioned by a migration, a DBA, or anything other than Brighter, set
MakeChannelstoValidateorAssume. Otherwise an application that has run happily for yearswill, on upgrade, run an existence probe at startup where nothing ran before — and where the
application's login has DML rights but no DDL rights, SQL Server answers error 262,
CREATE TABLE permission denied in database, out of channel open or producer construction.That case is now raised as a
ConfigurationExceptionnaming the queue table, carrying the provider'sown message, and naming the setting that avoids it. A login with no DDL rights can still use
Validate, because checking is oneSELECToversys.tables.Errors that will pass on their own — command and lock timeouts, the transport-level family, and the
Azure SQL unavailability and throttling codes — are deliberately left as the provider threw them, on
both the connect and the DDL, because telling their author to reconfigure
MakeChannelswould sendthem to fix the wrong thing, and because typing a failover window as a
ConfigurationExceptiondefeats any policy that retries on
SqlException.Number.A wrong server, a malformed connection string and a misspelled database name are not in that set
and stay wrapped: those are the first-run mistakes the message exists for.
Under
Create, the queue table name is bounded at 119 charactersSQL Server's identifier limit is 128, but the topic index is named after the table —
IX_<table>_Topic— so a longer name creates the table and then fails on the index with "Theidentifier that starts with … is too long", leaving a queue table that can never gain its index.
Provisioning refuses such a name up front with a message that explains the arithmetic.
Validatebuilds no identifier and keeps the full 128, so a longer table that already exists canstill be used.
Azure Service Bus: the channel factory provisions the subscription before handing out a channel (#4309)
AzureServiceBusChannelFactorynow honoursOnMissingChannelon anAzureServiceBusSubscriptionasit creates a channel, from
CreateSyncChannel,CreateAsyncChannelandCreateAsyncChannelAsyncalike, in the same way as the AWS and GCP channel factories:
MakeChannelsCreate(the default)UseServiceBusQueue, the queue) is created if absent, using the subscription'sAzureServiceBusSubscriptionConfigurationValidateChannelFailureExceptionfrom channel creationAssumeBefore this, the factory ignored the setting and created nothing on the broker. The subscription was
created by the consumer's first receive, so a message published to the topic between the channel
being created and that first receive arrived at a topic with no subscription, and Azure Service Bus
silently discarded it. Nothing logged and nothing threw. The queue path did not lose messages, because
the producer creates the same queue on send.
Behaviour change: provisioning failures now surface at startup
The management-API calls that used to happen on the first receive now happen when the
ServiceActivatorcreates its channels. WithCreateorValidate, a missing subscription underValidate, a management API that cannot be reached, or a credential without Manage rights on thenamespace now throws
ChannelFailureExceptionfrom channel creation, which is to say fromDispatcher.Receive()at startup. Before, the same exception came from the first receive, where themessage pump caught it and retried after
ChannelFailureDelay. There is no retry around channelcreation.
If your subscriptions are provisioned by infrastructure-as-code, or your application's credential
has only Send/Listen rights, set
MakeChannelstoAssume.Validatealso needs Manage rights,because checking whether a subscription exists is itself a management-API call.
AzureServiceBusConsumerFactory, used on its own without the channel factory, is unchanged: itsconsumers still provision on first use.
Full Changelog: 10.7.0...10.8.0
This discussion was created from the release 10.8.0.
All reactions