Skip to content

Provision BindFrom bindings for queues that opt in - #10335

Merged
PascalSenn merged 3 commits into
ChilliCream:mainfrom
alisan3:ali/mocha-bindfrom-autoprovision
Sep 5, 2026
Merged

PascalSenn merged 3 commits into
ChilliCream:mainfrom
alisan3:ali/mocha-bindfrom-autoprovision

Conversation

@alisan3

@alisan3 alisan3 commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

With a transport configured deny-by-default (transport.AutoProvision(false)), a queue that opts in and declares its binding via BindFrom never gets that binding created on the broker:

transport.Queue(queueName)
    .AutoProvision(true)                                       // queue IS created
    .Handler<GetProductRequestHandler>()
    .BindFrom(new Uri("rabbitmq:e/" + exchange), routingKey);  // binding is NOT created

The queue exists, the consumer attaches, the exchange exists, but the exchange-to-queue binding is absent, so nothing is ever delivered. No startup error, no runtime error, nothing logged. BindFrom was effectively unusable for any consumer running the transport deny-by-default, forcing every binding into the explicit three-call DeclareBinding form.

Root cause

ConfigureQueueTopology carries the queue's AutoProvision onto the queue topology descriptor via ApplyQueueConfiguration, but not onto the binding it derives from BindFrom. The per-resource gate binding.AutoProvision ?? transportDefault then falls back to false, and the derived binding configuration was unreachable from user code, so there was no way to compensate.

The Azure Service Bus and Postgres transports have the identical hole in the subscriptions their BindFrom derives, so the fix covers all three.

Fix

The derived binding or subscription resolves its provisioning as:

  1. an explicitly declared resource keeps its own setting (DeclareSubscription is get-or-add, so a later BindFrom must not override it),
  2. the binding's own opt-in from the new BindFrom overload,
  3. the queue's setting,
  4. the transport default.

At each derivation site this is one expression:

subscription.Extend().Configuration.AutoProvision ??=
    source.AutoProvision ?? configuration.Queue.AutoProvision;

The source exchange or topic is deliberately not inherited. It is owned elsewhere, and a deny-by-default host must not start declaring foreign resources; the explicit form this replaces (DeclareBinding(...).AutoProvision(true)) also only opts in the binding. A binding provisioned against a missing exchange fails loudly at startup, which is the correct failure mode. Tests pin the exchange and topic staying unset.

New overload

.BindFrom(source, routingKey, autoProvision: false)   // RabbitMQ
.BindFrom(source, autoProvision: false)               // Azure Service Bus, Postgres

The bool is required: omitting the argument already means inherit, so the overload exists only to decide, and a default would choose for the caller. For RabbitMQ the per-binding opt-out was otherwise inexpressible, since DeclareBinding always creates a new binding rather than returning the derived one.

Behavior changes

Both are consequences of the inheritance:

  • A deny-by-default transport now provisions the BindFrom bindings of queues that opt in. This is the reported defect.
  • A queue that opts out under a permissive transport now drags its BindFrom bindings out of provisioning with it. A host that relied on the transport default provisioning a binding for an externally managed queue can restore that with BindFrom(source, autoProvision: true). Tests pin this direction too.

💥 Breaking change — reviewer decision requested

AzureServiceBusQueueDescriptorConfiguration.SourceTopics and PostgresQueueDescriptorConfiguration.SourceBindings change from List<Uri> to lists of configuration objects (Source + AutoProvision), mirroring the record RabbitMQ already had, so the derived subscription can carry a per-binding setting. The new interface methods on the three queue descriptors are additive but formally breaking for external implementors of those interfaces.

What breaks what. The fix itself (the inheritance) is break-free on every transport; it only reads existing members. RabbitMQ's overload needed no config change, since its source-binding record already existed. The type change on the two sibling configurations is the price of offering the per-binding overload symmetrically. BindFrom(...) call sites compile unchanged everywhere; only code that reads or writes those two config properties directly is affected, and migration is mechanical (.Add(uri) becomes .Add(new(...) { Source = uri }), reads become x.Source).

Measured blast radius. The Azure Service Bus transport landed on 2026-08-19 and its package has a few hundred downloads, so this surface is two weeks old. The Postgres SourceBindings property dates to June with moderate package adoption, but the affected member is one a consumer only touches when writing conventions or tooling against the descriptor configuration.

Why this PR keeps it. The bare List<Uri> was under-modeled from the start: RabbitMQ already needed a record for its routing key, and Azure Service Bus subscriptions have rules and filters in their plausible future, which would force this exact type change later, with more adopters and a compatibility promise in place. Pre-GA is the cheapest this break will ever be.

The decision point. The packages ship as 16.6.2 with no preview suffix. If 16.x is considered under a compatibility promise, the fallback is to keep the inheritance fix everywhere (break-free), keep the overload only on RabbitMQ, where the per-binding opt-out is otherwise inexpressible because DeclareBinding always creates a new binding, and revert the record and overload on Azure Service Bus and Postgres. Their per-binding opt-out remains expressible through an explicit DeclareSubscription(...).AutoProvision(false), which the non-stomp tests in this PR pin either way. Happy to apply that reduction if versioning policy calls for it.

Tests

17 new tests across the three transports covering the full matrix: inheritance of the opt-in, inheritance of the opt-out, unset queue leaving the transport default in charge, the overload overriding the queue in both directions, the explicit declaration surviving a later BindFrom (ASB and Postgres, where the derived resource is get-or-add), and the source exchange or topic never inheriting.

Reverting only the three transport descriptor changes fails exactly the three inheritance tests, one per transport, with everything else green.

Suites: Mocha.Transport.RabbitMQ.Tests 1856/1856, Mocha.Transport.AzureServiceBus.Tests 1380 (8 pre-existing skips; one unrelated session-receive flake that also fails without this change), Mocha.Transport.Postgres.Tests 1416/1416, Mocha.Tests 4048/4048.

Example

The RabbitMQ transport example now declares the consumer's own binding with BindFrom on its queue chain and keeps DeclareBinding for the dead-letter binding, which has no local consumer, so the example teaches when each form applies.

🤖 Generated with Claude Code

With a transport configured deny-by-default, a queue that opts in to
provisioning and declares its binding via BindFrom never got that binding
created on the broker. ConfigureQueueTopology carried the queue's
AutoProvision onto the queue topology descriptor but not onto the binding it
derives, the per-resource gate then fell back to the transport default, and
the derived binding configuration was unreachable from user code. The queue
existed, the consumer attached, and nothing was delivered, with no error
raised anywhere. The same hole existed in the Azure Service Bus and Postgres
transports for the subscriptions their BindFrom derives.

The derived binding or subscription now resolves its provisioning as: an
explicitly declared resource keeps its own setting, then the binding's own
opt-in from the new BindFrom overload, then the queue's setting, then the
transport default. The source exchange or topic is deliberately not
inherited: it is owned elsewhere, and a deny-by-default host must not start
declaring foreign resources. A binding provisioned against a missing exchange
fails loudly at startup, which is the correct failure mode.

The BindFrom overload takes a required bool. Omitting the argument already
means inheriting, so the overload exists only to decide, and a default would
choose for the caller; for RabbitMQ the opt-out was otherwise inexpressible,
since DeclareBinding always creates a new binding rather than returning the
derived one.

Behavior changes, both consequences of the inheritance:

- A deny-by-default transport now provisions the BindFrom bindings of queues
  that opt in. This is the reported defect.
- A queue that opts out under a permissive transport now drags its BindFrom
  bindings out of provisioning with it. A host that relied on the transport
  default provisioning a binding for an externally managed queue can restore
  that with BindFrom(source, autoProvision: true).

Breaking change: the Azure Service Bus and Postgres queue descriptor
configurations store their BindFrom sources as configuration objects instead
of bare Uris, so the derived subscription can carry a per-binding setting.

The RabbitMQ example now declares the consumer's own binding with BindFrom on
its queue and keeps DeclareBinding for the dead-letter binding, which has no
local consumer.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
@PascalSenn
PascalSenn merged commit ba2a52f into ChilliCream:main Sep 5, 2026
147 checks passed
This was referenced Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants