My provisioning flow starts with a Service Bus message: a customer was created in the business system, a message lands on the provisioning queue, a Functions trigger picks it up and schedules a Durable Functions orchestration that builds the customer’s SharePoint site.
Now walk through the failure case with me. The trigger receives the message. It schedules the orchestration. And then, before it can complete the queue message, the process dies. Deployment restart, scale-in, hardware burp, doesn’t matter.
Service Bus does exactly what it promises: the message lock expires and the message comes back. A fresh trigger invocation receives it and schedules the orchestration.
Again.
Two orchestrations, one customer, and a race to see which one creates the site first. The other one gets to fail in interesting ways for the next ten minutes.
TL;DR
Derive the orchestration instance ID from the message instead of letting Durable Functions generate a random one. Before scheduling, check whether an instance with that ID already exists; if it does, the message is a redelivery, so complete it and walk away. The instance ID becomes your dedupe key, and “at-least-once delivery” stops meaning “at-least-once provisioning”.The Trigger, Guard by Guard
Here’s the subscriber, trimmed to the part that matters:
// Deterministic instance ID: if the process dies after scheduling but before completing the
// message, Service Bus redelivery hits the already-existing instance instead of provisioning twice.
var instanceId = $"provision-{message.MessageId}";
var existing = await client.GetInstanceAsync(instanceId, cancellation: cancellationToken);
if (existing is not null)
{
logger.LogWarning(
"Orchestration {InstanceId} already exists (status {Status}); completing redelivered message without rescheduling.",
instanceId, existing.RuntimeStatus);
await messageActions.CompleteMessageAsync(message, cancellationToken);
return;
}
await client.ScheduleNewOrchestrationInstanceAsync(
nameof(ProvisioningOrchestrator),
new OrchestratorInput(schema, ListItemId: listItemId, CustomSteps: CustomProvisioningSteps.Build()),
new StartOrchestrationOptions { InstanceId = instanceId },
cancellationToken);
await messageActions.CompleteMessageAsync(message, cancellationToken);
What’s happening here?
- The instance ID is computed from
message.MessageId, so the same message always maps to the same orchestration instance. No randomness anywhere in the path from message to instance. GetInstanceAsyncasks the task hub whether that instance already exists. On first delivery it doesn’t; on redelivery it does, in whatever state the first attempt left it -Running,Completed, evenFailed.- If it exists, the trigger completes the message and does nothing else. The redelivery was a symptom of the earlier crash, not a request for another site.
- Only when the instance is genuinely new does the trigger schedule it, passing the deterministic ID through
StartOrchestrationOptions. - The message is completed after scheduling. That ordering is the whole point, and it’s worth dwelling on.
The Order of Operations Is the Design
There are only two possible orderings, and both can crash in the middle:
Complete first, then schedule. A crash between the two loses the message forever - Service Bus considers it done, but no orchestration exists. A customer quietly never gets a site. Nobody notices until they complain.
Schedule first, then complete. A crash between the two causes a redelivery of a message whose orchestration already exists. Without the dedupe check, that’s a duplicate provisioning. With it, the redelivery is a no-op.
So you pick the second ordering, accept that duplicates are now possible, and make them harmless with the deterministic ID. This is the standard shape of at-least-once messaging: you can’t prevent redelivery, you can only make redelivery boring.
One prerequisite hiding in the function attributes: AutoCompleteMessages = false on the Service Bus trigger. With auto-complete on, the runtime settles the message on its own schedule and the careful ordering above is fiction. Explicit completion also means explicit dead-lettering - my trigger dead-letters messages with an unknown event type or a customer that doesn’t exist, instead of letting them retry into oblivion.
Choosing the Key: Delivery Identity vs Business Identity
provision-{message.MessageId} dedupes deliveries of one message. Same message redelivered: blocked. But if the upstream system sends two distinct messages for the same customer (double-click on “create”, integration replay, someone reruns a job), each has a fresh MessageId and both provision.
My local dev trigger, an HTTP endpoint for testing, actually uses a different scheme: provision-{listItemId}, keyed on the customer’s row in the intake list. That’s a business key. It dedupes at the customer level, no matter how many messages or HTTP calls ask for it.
So which is right? Depends on what “again” should mean:
- Delivery key (
MessageId): re-sending a message later does provision again. Right when upstream legitimately re-requests provisioning, such as a retry after a fixed config. - Business key (customer ID): one provisioning per customer, ever, until the old instance is purged. Right when duplicates upstream are common and re-provisioning is the rare, deliberate case.
I run the delivery key in production because my retry story goes through an explicit resume flow rather than “send the message again”, and the business system already guards against duplicate customer events. But I’d flip to a business key in a heartbeat if the upstream were flakier. The point is that the instance ID is not a cosmetic label - it’s the dedupe boundary, and you should choose it on purpose.
Whatever you pick, keep it deterministic and keep it readable. provision-4711 in the portal tells you which customer at a glance; a random GUID tells you nothing.
Telling the Caller What Happened
Because the instance ID is predictable, everything downstream gets simpler. The dev trigger returns the standard management payload:
return new AcceptedResult(location: null, value: client.CreateHttpManagementPayload(instanceId));
That’s a 202 with ready-made URLs for polling status, raising events, and terminating the instance. And since my orchestrator publishes its progress as custom status after every step, polling that URL shows exactly which provisioning step is running. Support can check on “the provisioning for customer 4711” without knowing anything about task hubs, because the instance ID is guessable from the customer.
Gotchas
- A
Failedinstance also blocks its ID.GetInstanceAsyncreturns failed and terminated instances too, and scheduling over an existing instance ID doesn’t reliably give you a clean second run. If “retry the failed provisioning” is a flow you need (it is), make it explicit: purge the old instance or start a new instance ID that carries a resume checkpoint. Deciding this after the first production failure is the wrong time. Ask me how I know. - The check-then-schedule pair isn’t atomic. Two redeliveries processed concurrently could both pass the
GetInstanceAsynccheck. In practice my queue runs withmaxConcurrentCalls: 1so this doesn’t occur, but if you crank concurrency, know that the guard is best-effort and the deterministic ID is what actually prevents two distinct instances - the second schedule call targets the same ID rather than minting a new orchestration. - Keep dev and prod schemes consciously different, or consciously the same. Mine differ (delivery key in prod, business key in dev), which is fine because I know why. Discovering by accident that your environments dedupe differently is less fine.
- Instance IDs live until purged. With a business key, “one per customer, ever” includes instances from last year. Build purging into your lifecycle (mine purges on cancellation) or old completed instances will block legitimate re-provisioning.
Wrapping Up
With at-least-once delivery, the question is never whether you’ll see a duplicate, only what it costs you. Derive the instance ID from the message, check for the existing instance before scheduling, and complete the message last. Then the answer is: one log line.
Instance IDs only make sense once orchestrations do. If this is your first stop, start with Durable Functions: A Function That Sleeps for a Week.
