Deploying a new solution version shouldn't mean breaking in-flight orders, orphaned approvals, or frantic rollbacks. This lesson gives you a complete, automation-ready playbook for quiescing triggers, draining active runs, and resuming traffic safely across environments — with no manual heroics required.
Picture this: your organization's order-processing flow has been running smoothly in production for six months. It handles 400+ runs per day, integrating your ERP, warehouse management system, and customer notification service. Your team has built a significant upgrade — new error handling, a refactored approval routing logic, and a critical bug fix for edge cases involving international orders. The upgrade is ready, the test environment checked out, and you've scheduled a deployment window.
Then you click "Import" in the Power Platform admin center and watch in horror as the existing flow momentarily turns off during the import, causing three in-flight order records to get stuck mid-run, two approval emails never get sent, and your monitoring dashboard starts lighting up like a Christmas tree.
This is the problem zero-downtime upgrade strategy solves. The goal isn't just to deploy faster — it's to deploy safely, with full awareness of what's running, where it's running, and how to ensure every piece of work reaches a valid terminal state before your new version takes over. By the end of this lesson, you'll have a complete, production-tested playbook for managing suspension and drain across Power Automate environments.
What you'll learn:
You should already be comfortable with:
Before you can design a drain strategy, you need to understand exactly what happens to a flow during a solution import.
When Power Platform imports a managed solution that contains a flow definition update, it performs the following operations in sequence: it suspends the existing flow, replaces the workflow definition in Dataverse, and then starts the new version. If you've simply clicked "Import" without any pre-work, active flow runs are in one of two situations:
Scenario A — The run completes before the swap. Lucky. Nothing bad happens. The old version finishes, the new version takes over future triggers.
Scenario B — The run was mid-execution when the definition changed. This is where things break. Power Automate runs are tightly coupled to the flow version that spawned them. When a flow is turned off during import, the platform makes a best-effort attempt to resume in-flight runs against the new definition, but action IDs, variable names, and branching structures that don't exist in the new version cause runs to fail with errors like ActionNotFound or WorkflowRunActionScopeRepetitionFailed.
Warning
Microsoft's documentation describes in-flight run behavior during imports as "undefined" in some scenarios. Do not rely on the platform to gracefully migrate a mid-run execution from one flow version to another. Your drain strategy is the only guaranteed protection.
There's a second problem that's subtler: trigger quiescing. If your flow uses a scheduled trigger or a Service Bus trigger, those will continue firing right up until the import begins. Even a 30-second gap between disabling triggers and starting the import can result in new runs being spawned against the old version, which then get orphaned.
The upshot: a production-grade upgrade requires three coordinated phases — suspend, drain, and resume — and each phase requires explicit tooling, not hope.
"Quiescing" means stopping new work from entering the system without discarding work that's already been accepted. The distinction matters enormously.
Scheduled flows are the easiest case. You simply turn the flow off before the import window. But "turning off" via the UI is manual, error-prone, and doesn't compose well into a pipeline. The right approach is to automate this using the Power Automate Management connector.
Build a dedicated "Suspension Orchestrator" flow — a cloud flow triggered by an HTTP request from your release pipeline — that calls the Management connector's Disable a Flow action:
Trigger: When an HTTP request is received
Body schema: { "environmentId": "string", "flowId": "string" }
Action: Disable a flow (Management connector)
Environment: triggerBody()?['environmentId']
Flow: triggerBody()?['flowId']
Action: Respond to HTTP request
Status code: 200
Body: { "status": "suspended", "timestamp": utcNow() }
Call this from your Azure DevOps pipeline or GitHub Actions workflow before the solution import step. Your pipeline then has a reliable, auditable record of when the flow was suspended. See Building CI/CD for Power Automate with Azure DevOps and the Power Platform Build Tools for how to embed this HTTP call as a pipeline task.
For flows triggered by Service Bus queues, HTTP webhooks, or Dataverse row changes, simply disabling the flow is not sufficient to quiesce traffic. Messages will continue arriving at the queue or endpoint. If you turn the flow off, the messages pile up unconsumed. When you re-enable the upgraded flow, it inherits that backlog — and you've got no guarantee the new version handles those messages the same way the old one did.
The correct approach for queue-triggered flows is a feature flag gate at the trigger boundary. Instead of having your flow trigger directly on the Service Bus queue, you front it with a routing flow:
Routing Flow (always running):
Trigger: When a message is received in a queue (Service Bus)
Action: Get environment variable "OrderProcessing_AcceptingWork"
Condition: variables('AcceptingWork') equals 'true'
True branch:
Action: Send message to "order-processing-active" queue
False branch:
Action: Send message to "order-processing-staging" queue
(messages park here during upgrade window)
Your main processing flow triggers on order-processing-active. During an upgrade, you flip the OrderProcessing_AcceptingWork environment variable to false, and new messages automatically park in the staging queue. After the upgrade and validation, you flip it back to true and the routing flow resumes forwarding.
Key insight
The routing-flow pattern decouples your trigger boundary from your processing logic. This is the same principle behind fronting HTTP flows with API Management, as described in Fronting Power Automate HTTP Endpoints with Azure API Management — you want a durable, stable entry point that you control independently of the processing logic behind it.
For Dataverse-triggered flows (row created, row modified), quiescing is more nuanced because Dataverse itself doesn't buffer — it fires and forgets. The safest pattern here is to gate inside the flow using a feature flag check as the very first action, sending non-accepted triggers to a dead-letter table for reprocessing after the upgrade.
Quiescing stops new runs from starting. Draining ensures all currently-executing runs reach a terminal state before you touch the flow definition.
The Power Platform Admin API exposes flow run status, and you should use it to confirm drain completion before proceeding. Here's how to query this from within Power Automate itself (via an HTTP action calling the management API):
GET https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/{environmentId}/flows/{flowId}/runs?$filter=status eq 'Running'&api-version=2016-11-01
Authorization: Bearer {managementToken}
Wire this into your Suspension Orchestrator flow as a polling loop:
Action: Initialize variable "RunningCount"
Type: Integer
Value: 1
Action: Do until
Condition: variables('RunningCount') equals 0
Limit: Count 60 (with 30-second delay each iteration — 30 minutes max)
Inside loop:
Action: HTTP GET to Admin API (flow runs filtered to 'Running')
Action: Parse JSON response
Action: Set variable "RunningCount"
Value: length(body('Parse_JSON')?['value'])
Condition: RunningCount greater than 0
True: Delay 30 seconds
False: (loop exits naturally)
Tip
Set a hard timeout on your drain loop. If runs haven't completed within your acceptable window (typically 15–30 minutes for most business flows), you need a decision point: extend the window, cancel the in-flight runs manually, or roll back the deployment. Infinite loops waiting for hung runs will block your entire release pipeline.
Occasionally a run will be genuinely stuck — waiting on an approval response that won't come, blocked on an external API that's down, or hung in a long-running loop. You have two options:
Option 1: Cancel the run. Use the Admin API to cancel specific runs:
POST https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/{environmentId}/flows/{flowId}/runs/{runId}/cancel?api-version=2016-11-01
Authorization: Bearer {managementToken}
Before cancelling, log the run ID, the trigger data, and the last successful action. This gives you enough information to replay the work manually or through a compensating flow after the upgrade.
Option 2: Let it ride. For flows using approval actions with long timeouts, cancellation mid-approval is destructive. The approver has already received an email, the record is in a pending state, and cancellation leaves it orphaned. In these cases, it may be better to let the approval complete (even under the old flow version) and accept that this run will produce results consistent with the old logic.
The decision depends on whether your flow logic is idempotent and whether partial execution leaves records in a valid state. See Designing Idempotent Flows: Preventing Duplicate Processing Under At-Least-Once Delivery for a deep treatment of this — idempotent flows are dramatically easier to reason about during drain windows because a cancelled-and-replayed run produces the same result as a completed one.
If you're running a high-volume flow (hundreds of runs per hour), the drain phase can take a while, and you want visibility into which runs are still executing and where in their execution they are. This is where correlation IDs earn their keep.
If you've implemented correlation tracking as described in Implementing Correlation IDs and End-to-End Distributed Tracing Across Power Automate Cloud Flows, your monitoring table already has a record per run with a current-action field. You can query this table during the drain window to see: "I have 14 runs still active; 12 are in the notification action (will complete in seconds), 2 are pending external approval (may take hours)."
This information directly informs your force-cancel decision. You're not guessing — you have data.
With triggers quiesced and runs drained, you're ready to import. But even here, there are decisions that affect your zero-downtime posture.
When importing a managed solution over an existing one, you have two behavioral modes:
Upgrade: The old solution version is completely replaced. Any components removed from the new version are deleted. This is what you want for major version bumps, but it's the most disruptive — the old flow is fully removed before the new one is activated.
Update: The new version is layered on top. Components not present in the import are retained. This is safer for incremental changes because the flow definition is updated in-place rather than deleted and recreated.
For zero-downtime purposes, Update mode is almost always preferable for flow definition changes. The flow experiences a brief suspension during definition replacement, but it's seconds rather than the longer disruption of a full component lifecycle.
Note
Connection references and environment variables are resolved at import time. If your new flow version references a connection reference that doesn't exist in the target environment, the import will succeed but the flow will fail at runtime. Validate your connection reference mapping before the import window — Implementing Connection References in Power Automate Solutions covers the full validation checklist.
Your pipeline should automate the import using the Power Platform Build Tools (for Azure DevOps) or Power Platform Actions (for GitHub Actions). A minimal pipeline stage looks like this:
# Azure DevOps pipeline step
- task: PowerPlatformImportSolution@2
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: '$(ServiceConnection)'
Environment: '$(TargetEnvironmentUrl)'
SolutionInputFile: '$(Pipeline.Workspace)/drop/OrderProcessing_2_1_0.zip'
AsyncOperation: true
MaxAsyncWaitTime: '60'
OverwriteUnmanagedCustomizations: false
SkipProductUpdateDependencies: false
ConvertToManaged: true
UseDeploymentSettingsFile: true
DeploymentSettingsFile: '$(Pipeline.Workspace)/drop/deployment-settings-prod.json'
The DeploymentSettingsFile is where you inject environment-specific values — connection reference IDs, environment variable values, and flow activation state. Set the flow's activation state to false in the deployment settings for all flows you want to remain suspended immediately post-import:
{
"EnvironmentVariables": [
{
"SchemaName": "wsd_OrderProcessing_AcceptingWork",
"Value": "false"
}
],
"ConnectionReferences": [
{
"LogicalName": "wsd_SharedServiceBus",
"ConnectionId": "/providers/Microsoft.PowerApps/apis/shared_servicebus/connections/abc123",
"ConnectorId": "/providers/Microsoft.PowerApps/apis/shared_servicebus"
}
]
}
This ensures that even if something goes wrong in your post-import validation, no new runs start until you explicitly re-enable the flow.
The biggest mistake teams make is treating "import succeeded" as equivalent to "deployment succeeded." A successful import means the solution components are in Dataverse. It says nothing about whether the flow runs correctly.
Build a dedicated smoke-test flow that you trigger immediately after import. This flow should:
Smoke Test Flow:
Trigger: HTTP request
Action: Create test order record in ERP (with IsTestRecord = true)
Action: Wait 30 seconds (allow trigger to fire)
Action: HTTP GET - Query Admin API for recent runs of [new flow version]
Action: Parse JSON
Condition: Any run with status 'Succeeded' in last 2 minutes?
True:
Action: Verify test record status in ERP = 'Processed'
Action: Delete test record (cleanup)
Action: Respond 200 "smoke_test_passed"
False:
Action: Respond 500 "smoke_test_failed"
Your pipeline treats a 500 response as a deployment failure and triggers your rollback runbook.
Warning
Smoke tests that touch real production data create their own risks. Use dedicated test records with an IsTest flag that all downstream systems are configured to ignore. Never run smoke tests against customer-facing records without a cleanup step.
Before re-enabling traffic, run a quick health check against your environment variables and connections. This is especially important if the new solution version introduced new environment variables that need production values — an unset environment variable will cause null reference errors at runtime in a way that's difficult to distinguish from a code bug.
The Power Platform Admin API can retrieve environment variable current values:
GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/{environmentId}/settings?api-version=2020-10-01
Cross-reference this against your expected variable manifest (which should be version-controlled in your repo alongside the solution).
You've imported, validated, and you're ready to re-enable. Don't just flip everything on at once.
For high-volume flows, a graduated resume reduces blast radius if the new version has a subtle bug that only manifests at scale. The pattern:
This is more operationally complex than a hard cutover, but for critical, high-volume flows it's the difference between a 10-record incident and a 10,000-record incident.
Routing Flow (graduated resume):
Trigger: Service Bus message received
Action: Get "OrderProcessing_TrafficPercentage" environment variable
(value: 0, 10, 50, or 100 — set by your pipeline or manually)
Action: Compose random number
Value: rand(1, 100)
Condition: random number <= TrafficPercentage
True: Route to new version queue
False: Route to staging queue (held for later)
Tip
Store your traffic percentage in an environment variable rather than hardcoding it. This lets you adjust the rollout percentage in real-time through the Power Platform Admin Center or via API without redeploying anything. See Defining and Enforcing Environment Variable Strategies in Power Automate Solutions for how to structure these as first-class configuration artifacts.
Once you're at 100% traffic on the new version and your error rate is healthy, drain the staging queue. This is the backlog of messages that arrived during your suspension window. Process them through the new version:
AcceptingWork variable back to trueorder-processing-staging and forwards to order-processing-activeThis last point is why backwards compatibility in your message schemas matters. If the new flow version expects a field that older messages don't have, you need a default-value fallback in your trigger schema handling.
Here's the complete sequence as a runbook you can adapt to your environment:
Pre-deployment (T-60 minutes):
Suspension phase (T-15 minutes):
AcceptingWork = falseDrain phase (T-15 to T-0):
RunningCount = 0Import phase (T+0):
Validation phase (T+5 minutes):
Graduated resume (T+10 minutes):
TrafficPercentage = 10; monitor for 15 minutesTrafficPercentage = 50; monitor for 15 minutes TrafficPercentage = 100 and AcceptingWork = trueStaging drain (T+40 minutes):
Scenario: You manage a Dataverse-triggered flow called ProcessInvoiceApproval that fires when an invoice record is updated to status PendingApproval. It runs roughly 200 times per day and includes a multi-step approval chain that can take up to 4 hours to complete. You need to deploy a new version that changes the approval routing logic.
Your task: Build the following components:
Part 1 — Suspension Orchestrator (30 minutes) Build an HTTP-triggered cloud flow that accepts an environment ID and flow ID, disables the target flow using the Management connector, and returns a JSON response with the flow's last-run timestamp queried from the Admin API.
Part 2 — Drain Monitor (30 minutes) Extend your Suspension Orchestrator with a Do Until loop that polls the Admin API every 60 seconds for active runs of the target flow. Have it post a Teams message (or write to a Dataverse table) with the current running count at each iteration. Add a hard timeout of 4 hours to accommodate long-running approval chains.
Part 3 — Feature Flag Gate (20 minutes)
Add a "Check feature flag" condition as the very first action in your ProcessInvoiceApproval flow (before any Dataverse reads or approvals). The condition reads a InvoiceApproval_AcceptingWork boolean environment variable. If false, it writes the trigger data to a SuspendedInvoiceTriggers Dataverse table with a status of Pending, and then terminates. Build a companion "Replay Suspended Triggers" flow that reads from this table and re-triggers the approval flow for each pending record once you set the flag back to true.
Part 4 — Smoke Test (20 minutes)
Build a smoke-test flow that creates a test invoice record (with IsTestRecord = true), waits 2 minutes, queries the Admin API for recent successful runs, and verifies the test record's approval status was updated correctly.
Mistake: Disabling the flow too early and losing trigger events If you disable a Service Bus or HTTP-triggered flow without a routing layer in front of it, triggers during the suspension window are simply lost. Always implement the routing/staging pattern before you need your first upgrade window. This infrastructure should be in place from day one of a production flow.
Mistake: Assuming "Import Succeeded" means the flow is enabled A successful managed solution import does not guarantee the flow is in a running state. Flows can import in a "suspended" state (especially if the connection references aren't fully resolved). Always query the flow's status via Admin API after import, not just the import operation's success status.
Mistake: Not accounting for child flows If your main flow uses orchestrated child flows, you need to include them in your drain strategy. A parent flow can be fully drained, but if a child flow is upgraded mid-execution of a parent that called it, you get the same definition-mismatch failures. Drain and upgrade child flows in dependency order — leaf flows first, orchestrator last.
Mistake: Setting drain timeout too short Approval flows, flows waiting on external callbacks, and event-driven integration flows can legitimately run for hours. A 10-minute drain timeout that force-cancels these runs is worse than not having a drain strategy at all, because now you have orphaned approval records and incomplete state in your systems of record. Set your drain timeout based on the actual P99 run duration of your flow, which you can derive from your monitoring and alerting system.
Mistake: Forgetting to handle the staging queue after resume Teams carefully suspend, drain, import, and validate — then re-enable the flow and forget that 3 hours of messages are sitting in the staging queue. These messages are now processed hours late, which may violate SLAs or cause timestamp-dependent logic to fail. Always include staging queue drain as a mandatory step in your runbook, not an afterthought.
Troubleshooting: Admin API returns 403 on flow run queries
The Management connector and Admin API require specific permissions. The service principal or user account running your Suspension Orchestrator needs the Flow service's Flows.Read.All and Flows.Manage.All permissions, and the account must be an Environment Admin or System Administrator in the target environment. Check your service principal setup against the guidance in Service Principals and Application Users for Unattended Power Automate Deployments.
Troubleshooting: Runs show as "Running" in Admin API but aren't actually executing This happens with flows that are waiting on an approval or an HTTP callback — they're genuinely suspended, waiting for external input. These show as "Running" in the API. You can't distinguish them from actively-executing runs via status alone. Query the run's action history to find the last completed action, and use that to determine whether the run is truly active or just waiting. A run that's been in "Running" status for longer than its typical P99 is almost certainly waiting, not executing.
Zero-downtime upgrades in Power Automate aren't a single feature — they're a discipline composed of several coordinated patterns: trigger quiescing, run draining, validated import, and graduated resume. The platform doesn't hand you a blue-green deployment button. You build the equivalent yourself using the Management connector, Admin APIs, environment variables as feature flags, and staging queues as traffic buffers.
The key mental model to internalize is the separation of concerns across the phases. Quiescing is about the trigger boundary — stopping new work from entering. Draining is about existing work — letting it reach a valid terminal state. Import is about the definition — updating the code while no work is in flight. Resume is about confidence — re-enabling traffic only after validation passes.
When these phases are automated and built into your release pipeline, what used to be a risky, manually-coordinated production event becomes a routine, repeatable process that your team can execute with confidence.
Where to go from here: