Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cli/azd/cmd/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ func TestTelemetryFieldConstants(t *testing.T) {

measurementFields := []fields.AttributeKey{
fields.AgentFixAttempts,
fields.ExeGraphDeployConcurrencyKey,
fields.ExeGraphMaxConcurrencyKey,
fields.ExeGraphPackageConcurrencyKey,
fields.ExeGraphProvisionConcurrencyKey,
fields.ToolExitCode,
}
for _, field := range measurementFields {
Expand All @@ -112,6 +115,25 @@ func TestTelemetryFieldConstants(t *testing.T) {
require.False(t, fields.ServiceErrorCode.IsMeasurement)
})

t.Run("ExecutionGraphConcurrencyFields", func(t *testing.T) {
t.Parallel()

concurrencyFields := []struct {
field fields.AttributeKey
key string
}{
{fields.ExeGraphPackageConcurrencyKey, "exegraph.package_concurrency"},
{fields.ExeGraphProvisionConcurrencyKey, "exegraph.provision_concurrency"},
{fields.ExeGraphDeployConcurrencyKey, "exegraph.deploy_concurrency"},
}
for _, tt := range concurrencyFields {
require.Equal(t, tt.key, string(tt.field.Key))
require.Equal(t, fields.SystemMetadata, tt.field.Classification)
require.Equal(t, fields.PerformanceAndHealth, tt.field.Purpose)
require.True(t, tt.field.IsMeasurement)
}
})

// Hooks command telemetry fields
t.Run("HooksFields", func(t *testing.T) {
t.Parallel()
Expand Down
30 changes: 30 additions & 0 deletions cli/azd/docs/concurrency-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ it protects are co-located by convention.

---

## Scheduler limits and phase groups

The graph scheduler applies a hard global ceiling and optional limits for named
groups. It admits ready work in round-robin order across groups while preserving
critical-path priority within each group. Limits are maxima, not reservations.
When other groups have no ready work, one group can use every available global
slot up to its own limit.

The scheduler coordinator enforces all limits before dispatch. Workers never
wait for group capacity, so package work cannot occupy every worker while it
waits for another package step to finish. Active work is not preempted, but a
continuously ready group cannot starve another ready group when slots become
available.

| Command | Hard global ceiling | Phase groups |
|---------|---------------------|--------------|
| `azd up` | `AZD_CONCURRENCY_MAX`, then `AZD_UP_CONCURRENCY`, then `AZD_DEPLOY_CONCURRENCY`, then the scheduler default | Package: `AZD_PACKAGE_CONCURRENCY`, then `AZD_UP_CONCURRENCY`; provision: `AZD_PROVISION_CONCURRENCY`, then `AZD_UP_CONCURRENCY`; publish and deploy: `AZD_DEPLOY_CONCURRENCY`, then `AZD_UP_CONCURRENCY` |
| `azd deploy` | `AZD_CONCURRENCY_MAX`, then `AZD_DEPLOY_CONCURRENCY`, then the scheduler default | Package: `AZD_PACKAGE_CONCURRENCY`, then `AZD_DEPLOY_CONCURRENCY`; publish and deploy: `AZD_DEPLOY_CONCURRENCY` |
| `azd provision` | `AZD_CONCURRENCY_MAX`, then `AZD_PROVISION_CONCURRENCY`, then the scheduler default | Provision: `AZD_PROVISION_CONCURRENCY` |

Package and provision work in `azd up` can overlap while retaining independent
limits. Publish and deploy share one budget because both are part of the
deployment phase. Standalone `azd package` remains sequential.

All configured values are positive integers clamped to `64`. An explicitly set
invalid or non-positive value disables that limit and blocks fallback. Fallback
occurs only when the higher-precedence variable is unset.

---

## Service Deploy Ordering

Service deployment uses a **sequential-by-default** model to preserve
Expand Down
10 changes: 7 additions & 3 deletions cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,18 @@ integration.
| `AZD_CONTAINER_RUNTIME` | The container runtime to use (e.g., `docker`, `podman`). |
| `AZD_ALLOW_NON_EMPTY_FOLDER` | If set, allows `azd init` to run in a non-empty directory without prompting. |
| `AZD_BUILDER_IMAGE` | The builder docker image used to perform Dockerfile-less builds. |
| `AZD_DEPLOY_CONCURRENCY` | Maximum number of services to deploy in parallel during `azd deploy`. Only takes effect when at least one service declares `uses:` targeting another service; without `uses:` edges, services deploy sequentially in alphabetical order for backward compatibility (see [concurrency model](concurrency-model.md)). Parsed as a positive integer; clamped to a maximum of `64`. When unset, concurrency is unlimited (bounded only by the number of services). |
| `AZD_CONCURRENCY_MAX` | Hard maximum number of graph steps that can run at once during `azd up`, `azd deploy`, or `azd provision`. Values saved in the active azd environment take precedence over process environment values. When unset, the command-specific concurrency variable is the hard maximum: `AZD_UP_CONCURRENCY` (then `AZD_DEPLOY_CONCURRENCY`) for `azd up`, `AZD_DEPLOY_CONCURRENCY` for `azd deploy`, or `AZD_PROVISION_CONCURRENCY` for `azd provision`. When all are unset, the scheduler uses `min(stepCount, GOMAXPROCS*2)`. |
| `AZD_PACKAGE_CONCURRENCY` | Maximum number of service package steps that can run at once during `azd up` or `azd deploy`. Falls back to `AZD_UP_CONCURRENCY` for `azd up` and `AZD_DEPLOY_CONCURRENCY` for `azd deploy`. Standalone `azd package` remains sequential. |
| `AZD_PROVISION_CONCURRENCY` | Maximum number of infrastructure layer provision steps that can run at once during `azd provision` or `azd up`. Falls back to `AZD_UP_CONCURRENCY` during `azd up`. During `azd provision`, it is also the hard maximum when `AZD_CONCURRENCY_MAX` is unset. |
| `AZD_DEPLOY_CONCURRENCY` | Maximum combined number of service publish and deploy steps that can run at once during `azd deploy` or `azd up`. It is also the package-step fallback and hard maximum for `azd deploy`. During `azd up`, it falls back to `AZD_UP_CONCURRENCY` for the group limit, and it remains the last hard-maximum fallback when `AZD_CONCURRENCY_MAX` and `AZD_UP_CONCURRENCY` are both unset. Without service `uses:` edges, deploy steps remain sequential in alphabetical order, but publish steps can still run in parallel (see [concurrency model](concurrency-model.md)). |
| `AZD_UP_CONCURRENCY` | Fallback maximum for each package, provision, and combined publish/deploy phase during `azd up`. It is also the hard maximum for the full `azd up` graph when `AZD_CONCURRENCY_MAX` is unset. |
| `AZD_DEPLOY_TIMEOUT` | Timeout for deployment operations, parsed as an integer number of seconds (for example, `1200`). Defaults to `1200` seconds (20 minutes). |
| `AZD_PROVISION_CONCURRENCY` | Maximum number of infrastructure layers to provision in parallel during `azd provision`. Parsed as a positive integer; clamped to a maximum of `64`. When unset, concurrency is unlimited (bounded only by the dependency graph). |
| `AZD_DEPLOYMENT_ID_FILE` | Absolute path of a file where `azd` writes ARM deployment IDs in NDJSON format (one JSON line per layer) during `azd provision` or `azd up`. The file is truncated at the start of each provisioning run, and each infrastructure layer appends one line as its ARM deployment starts. Each line has the shape `{"deploymentId":"/subscriptions/.../deployments/<name>","layer":"<layer-name>"}` — the `layer` field is empty for non-layered (single-module) provisioning. Consumers should tail/watch the file and parse each line independently; unknown fields must be ignored for forward compatibility. The path must be absolute (relative paths are ignored); the containing directory must already exist and be writable. Lines are only appended when an ARM deployment is actually started — runs short-circuited by the deployment-state cache or canceled by provision validation do not produce output. A process-wide mutex serializes writes so each line is always complete. If the file cannot be written (for example, the parent directory does not exist, the path is not writable, or the path points to a directory rather than a file), provisioning continues and the failure is recorded via the standard log; that output is only visible when `--debug` or `AZD_DEBUG_LOG` is enabled. On Windows, consumers should use a file-watcher pattern that does not keep a read handle open, otherwise new appends may fail. Only Bicep deployments are supported. |
| `AZD_UP_CONCURRENCY` | Maximum number of steps to run in parallel during `azd up`. Parsed as a positive integer; clamped to a maximum of `64`. Falls back to `AZD_DEPLOY_CONCURRENCY` when unset. When both are unset, concurrency is unlimited. |
| `AZD_DEPLOY_{SERVICE}_SLOT_NAME` | Sets the App Service deployment slot target for a service. Replace `{SERVICE}` with the uppercase service name (hyphens become underscores). Set to `production` to deploy to the main app, or a slot name (e.g., `staging`). When slots exist and this is not set, `--no-prompt` mode fails with an error listing available targets. Applies to `host: appservice` only; Function Apps always deploy to the main site. |
| `AZD_DEPLOY_{SERVICE}_SKIP_STATUS_CHECK` | If `true`, skips deployment status tracking for the named Linux App Service after the zip deployment request is accepted. By default, azd waits up to five minutes without a deployment status change. Each new status resets the five-minute wait. If the status remains unchanged, azd completes deployment with a warning. Useful when the target web app is intentionally stopped. Parsed as a boolean (`true`/`false`/`1`/`0`). `{SERVICE}` follows the same naming rules as `AZD_DEPLOY_{SERVICE}_SLOT_NAME`. |

All concurrency variables are parsed as positive integers and clamped to `64`. If a variable is explicitly set to an invalid or non-positive value, its limit is disabled and azd does not consult that variable's fallback. Fallback occurs only when the higher-precedence variable is unset.

## azd exec

The `azd exec` command runs commands and scripts with the active azd environment loaded into the child
Expand Down
121 changes: 121 additions & 0 deletions cli/azd/internal/cmd/concurrency.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"log"
"strconv"
"strings"
)

const (
concurrencyMaxEnvVar = "AZD_CONCURRENCY_MAX"
packageConcurrencyEnvVar = "AZD_PACKAGE_CONCURRENCY"
provisionConcurrencyEnvVar = "AZD_PROVISION_CONCURRENCY"
deployConcurrencyEnvVar = "AZD_DEPLOY_CONCURRENCY"
upConcurrencyEnvVar = "AZD_UP_CONCURRENCY"

packageConcurrencyGroup = "package"
provisionConcurrencyGroup = "provision"
deployConcurrencyGroup = "deploy"

maxConfiguredConcurrency = 64
)

type environmentLookup func(string) (string, bool)

type concurrencySetting struct {
value int
set bool
}

type graphConcurrencyOptions struct {
max int
groups map[string]int
}

func resolveConcurrencySetting(lookup environmentLookup, envName string) concurrencySetting {
envValue, ok := lookup(envName)
if !ok {
return concurrencySetting{}
}

setting := concurrencySetting{set: true}
value, err := strconv.Atoi(envValue)
if err != nil {
log.Printf("warning: ignoring invalid %s=%q: %v", envName, envValue, err)
return setting
}
if value <= 0 {
Comment thread
RickWinter marked this conversation as resolved.
log.Printf(
"warning: ignoring invalid %s=%q: value must be greater than zero; "+
"lower-precedence concurrency settings will not apply",
envName,
envValue,
)
return setting
}

setting.value = min(value, maxConfiguredConcurrency)
if setting.value < value {
label := strings.ToLower(strings.ReplaceAll(strings.TrimPrefix(envName, "AZD_"), "_", " "))
log.Printf("clamping %s from %d to %d", label, value, setting.value)
}
return setting
}

func firstConcurrency(settings ...concurrencySetting) int {
for _, setting := range settings {
if setting.set {
return setting.value
}
}
return 0
}

func resolveUpGraphConcurrency(lookup environmentLookup) graphConcurrencyOptions {
maxSetting := resolveConcurrencySetting(lookup, concurrencyMaxEnvVar)
upSetting := resolveConcurrencySetting(lookup, upConcurrencyEnvVar)
packageSetting := resolveConcurrencySetting(lookup, packageConcurrencyEnvVar)
provisionSetting := resolveConcurrencySetting(lookup, provisionConcurrencyEnvVar)
deploySetting := resolveConcurrencySetting(lookup, deployConcurrencyEnvVar)

// AZD_DEPLOY_CONCURRENCY remains the last hard-ceiling fallback so users who
// tuned `azd deploy` parallelism before `azd up` gained its own variable do
// not silently get the unbounded scheduler default for the whole graph.
return graphConcurrencyOptions{
max: firstConcurrency(maxSetting, upSetting, deploySetting),
groups: map[string]int{
packageConcurrencyGroup: firstConcurrency(packageSetting, upSetting),
provisionConcurrencyGroup: firstConcurrency(provisionSetting, upSetting),
deployConcurrencyGroup: firstConcurrency(deploySetting, upSetting),
},
}
}

func resolveDeployGraphConcurrency(lookup environmentLookup) graphConcurrencyOptions {
maxSetting := resolveConcurrencySetting(lookup, concurrencyMaxEnvVar)
packageSetting := resolveConcurrencySetting(lookup, packageConcurrencyEnvVar)
deploySetting := resolveConcurrencySetting(lookup, deployConcurrencyEnvVar)

return graphConcurrencyOptions{
max: firstConcurrency(maxSetting, deploySetting),
groups: map[string]int{
packageConcurrencyGroup: firstConcurrency(packageSetting, deploySetting),
deployConcurrencyGroup: deploySetting.value,
},
}
}

func resolveProvisionGraphConcurrency(lookup environmentLookup) graphConcurrencyOptions {
maxSetting := resolveConcurrencySetting(lookup, concurrencyMaxEnvVar)
provisionSetting := resolveConcurrencySetting(lookup, provisionConcurrencyEnvVar)

return graphConcurrencyOptions{
max: firstConcurrency(maxSetting, provisionSetting),
groups: map[string]int{
provisionConcurrencyGroup: provisionSetting.value,
},
}
}
50 changes: 50 additions & 0 deletions cli/azd/internal/cmd/concurrency_feedback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"bytes"
"log"
"testing"

"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/stretchr/testify/assert"
)

func TestResolveConcurrencySettingWarnsForNonPositiveValues(t *testing.T) {
var output bytes.Buffer
originalWriter := log.Writer()
log.SetOutput(&output)
t.Cleanup(func() {
log.SetOutput(originalWriter)
})

for _, value := range []string{"0", "-1"} {
output.Reset()
setting := resolveConcurrencySetting(lookupEnvironment(map[string]string{
packageConcurrencyEnvVar: value,
}), packageConcurrencyEnvVar)

assert.True(t, setting.set)
assert.Zero(t, setting.value)
assert.Contains(t, output.String(), "value must be greater than zero")
assert.Contains(t, output.String(), "lower-precedence concurrency settings will not apply")
}
}

func TestUpGraphRunOptionsUsesActiveEnvironment(t *testing.T) {
t.Setenv(packageConcurrencyEnvVar, "1")
t.Setenv(upConcurrencyEnvVar, "2")
env := environment.NewWithValues("test", map[string]string{
packageConcurrencyEnvVar: "3",
upConcurrencyEnvVar: "4",
})

opts := (&UpGraphAction{env: env}).runOptions()

assert.Equal(t, 4, opts.MaxConcurrency)
assert.Equal(t, 3, opts.GroupConcurrency[packageConcurrencyGroup])
assert.Equal(t, 4, opts.GroupConcurrency[provisionConcurrencyGroup])
assert.Equal(t, 4, opts.GroupConcurrency[deployConcurrencyGroup])
}
Loading
Loading