Skip to content

Commit d210dcc

Browse files
jongioCopilot
andcommitted
perf: performance foundations for parallel deployment execution
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 05dee37 commit d210dcc

21 files changed

+1141
-102
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,10 @@ cli/azd/extensions/microsoft.azd.concurx/concurx.exe
7878
cli/azd/extensions/azure.appservice/azureappservice
7979
cli/azd/extensions/azure.appservice/azureappservice.exe
8080
.squad/
81+
82+
# Session artifacts
83+
cli/azd/cover-*
84+
cli/azd/cover_*
85+
review-*.diff
86+
87+
.playwright-mcp/

.vscode/cspell.misc.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ overrides:
3939
- filename: ./README.md
4040
words:
4141
- VSIX
42+
- filename: ./docs/specs/perf-foundations/**
43+
words:
44+
- Alives
45+
- appsettings
46+
- appservice
47+
- armdeploymentstacks
48+
- azapi
49+
- azsdk
50+
- containerapps
51+
- golangci
52+
- goroutines
53+
- httputil
54+
- keepalives
55+
- nilerr
56+
- nolint
57+
- remotebuild
58+
- resourcegroup
59+
- singleflight
60+
- stdlib
61+
- whatif
4262
- filename: schemas/**/azure.yaml.json
4363
words:
4464
- prodapi

cli/azd/.vscode/cspell-azd-dictionary.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ appinsightsexporter
4646
appinsightsstorage
4747
appplatform
4848
appservice
49+
appsettings
4950
appuser
5051
arget
5152
armapimanagement
@@ -174,6 +175,7 @@ jmespath
174175
jongio
175176
jquery
176177
keychain
178+
keepalives
177179
kubelogin
178180
langchain
179181
langchaingo
@@ -204,6 +206,7 @@ mysqlclient
204206
mysqldb
205207
nazd
206208
ndjson
209+
nilerr
207210
nobanner
208211
nodeapp
209212
nolint
@@ -248,9 +251,11 @@ rabbitmq
248251
reauthentication
249252
relogin
250253
remarshal
254+
remotebuild
251255
repourl
252256
requirepass
253257
resourcegraph
258+
resourcegroup
254259
restoreapp
255260
retriable
256261
runtimes
@@ -303,6 +308,7 @@ vuejs
303308
webappignore
304309
webfrontend
305310
westus2
311+
whatif
306312
wireinject
307313
yacspin
308314
yamlnode

cli/azd/cmd/deps.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ package cmd
88
import (
99
"net/http"
1010

11+
"github.com/azure/azure-dev/cli/azd/pkg/httputil"
1112
"github.com/benbjohnson/clock"
1213
)
1314

1415
func createHttpClient() *http.Client {
15-
return http.DefaultClient
16+
return &http.Client{Transport: httputil.TunedTransport()}
1617
}
1718

1819
func createClock() clock.Clock {

cli/azd/pkg/auth/federated_token_client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ func (c *FederatedTokenClient) TokenForAudience(ctx context.Context, audience st
3232
if err != nil {
3333
return "", fmt.Errorf("sending request: %w", err)
3434
}
35-
defer res.Body.Close()
3635

3736
if !runtime.HasStatusCode(res, http.StatusOK) {
37+
defer res.Body.Close()
3838
return "", fmt.Errorf("expected 200 response, got: %d", res.StatusCode)
3939
}
4040

cli/azd/pkg/azapi/container_registry.go

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
"net/url"
1414
"slices"
1515
"strings"
16+
"sync"
17+
"time"
1618

1719
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1820
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
@@ -25,6 +27,7 @@ import (
2527
"github.com/azure/azure-dev/cli/azd/pkg/azure"
2628
"github.com/azure/azure-dev/cli/azd/pkg/httputil"
2729
"github.com/azure/azure-dev/cli/azd/pkg/tools/docker"
30+
"golang.org/x/sync/singleflight"
2831
)
2932

3033
// Credentials for authenticating with a docker registry,
@@ -61,6 +64,12 @@ type containerRegistryService struct {
6164
docker *docker.Cli
6265
armClientOptions *arm.ClientOptions
6366
coreClientOptions *azcore.ClientOptions
67+
// loginGroup deduplicates concurrent login attempts to the same registry.
68+
// When multiple services share one ACR, only the first goroutine performs
69+
// the credential exchange + docker login; others wait for its result.
70+
loginGroup singleflight.Group
71+
// loginDone tracks registries that have already been authenticated this session.
72+
loginDone sync.Map
6473
}
6574

6675
// Creates a new instance of the ContainerRegistryService
@@ -118,20 +127,51 @@ func (crs *containerRegistryService) FindContainerRegistryResourceGroup(
118127
}
119128

120129
func (crs *containerRegistryService) Login(ctx context.Context, subscriptionId string, loginServer string) error {
121-
dockerCreds, err := crs.Credentials(ctx, subscriptionId, loginServer)
122-
if err != nil {
123-
return err
130+
cacheKey := subscriptionId + ":" + loginServer
131+
if _, ok := crs.loginDone.Load(cacheKey); ok {
132+
log.Printf("skipping redundant login to '%s' (already authenticated this session)\n", loginServer)
133+
return nil
124134
}
125135

126-
err = crs.docker.Login(ctx, dockerCreds.LoginServer, dockerCreds.Username, dockerCreds.Password)
127-
if err != nil {
128-
return fmt.Errorf(
129-
"failed logging into docker registry %s: %w",
130-
loginServer,
131-
err)
132-
}
136+
// singleflight deduplicates concurrent login attempts to the same registry.
137+
// We use DoChan so each caller can select on its own ctx.Done(), avoiding the
138+
// problem where one caller's cancellation fails all waiters.
139+
ch := crs.loginGroup.DoChan(cacheKey, func() (any, error) {
140+
// Double-check after winning the singleflight race.
141+
if _, ok := crs.loginDone.Load(cacheKey); ok {
142+
return nil, nil
143+
}
144+
145+
// Use context.WithoutCancel so the shared work isn't tied to a single
146+
// caller's context. Add a bounded timeout so the shared login cannot
147+
// hang indefinitely if Credentials or docker login gets stuck.
148+
const loginTimeout = 5 * time.Minute
149+
opCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginTimeout)
150+
defer cancel()
151+
152+
dockerCreds, err := crs.Credentials(opCtx, subscriptionId, loginServer)
153+
if err != nil {
154+
return nil, err
155+
}
156+
157+
err = crs.docker.Login(opCtx, dockerCreds.LoginServer, dockerCreds.Username, dockerCreds.Password)
158+
if err != nil {
159+
return nil, fmt.Errorf(
160+
"failed logging into docker registry %s: %w",
161+
loginServer,
162+
err)
163+
}
133164

134-
return nil
165+
crs.loginDone.Store(cacheKey, true)
166+
return nil, nil
167+
})
168+
169+
select {
170+
case <-ctx.Done():
171+
return ctx.Err()
172+
case res := <-ch:
173+
return res.Err
174+
}
135175
}
136176

137177
// Credentials gets the credentials that could be used to login to the specified container registry. It prefers to use

cli/azd/pkg/azapi/resource_service.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"net/http"
11+
"sync"
1112

1213
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1314
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
@@ -61,6 +62,13 @@ type ListResourceGroupResourcesOptions struct {
6162
type ResourceService struct {
6263
credentialProvider account.SubscriptionCredentialProvider
6364
armClientOptions *arm.ClientOptions
65+
66+
// resourcesClients caches armresources.Client instances per subscription ID.
67+
// Azure SDK ARM clients are safe for concurrent use.
68+
resourcesClients sync.Map // map[string]*armresources.Client
69+
70+
// resourceGroupClients caches ResourceGroupsClient instances per subscription ID.
71+
resourceGroupClients sync.Map // map[string]*armresources.ResourceGroupsClient
6472
}
6573

6674
func NewResourceService(
@@ -320,6 +328,10 @@ func (rs *ResourceService) DeleteResourceGroup(ctx context.Context, subscription
320328
}
321329

322330
func (rs *ResourceService) createResourcesClient(ctx context.Context, subscriptionId string) (*armresources.Client, error) {
331+
if cached, ok := rs.resourcesClients.Load(subscriptionId); ok {
332+
return cached.(*armresources.Client), nil
333+
}
334+
323335
credential, err := rs.credentialProvider.CredentialForSubscription(ctx, subscriptionId)
324336
if err != nil {
325337
return nil, err
@@ -330,13 +342,19 @@ func (rs *ResourceService) createResourcesClient(ctx context.Context, subscripti
330342
return nil, fmt.Errorf("creating Resource client: %w", err)
331343
}
332344

333-
return client, nil
345+
// Benign race: concurrent miss creates an extra client; LoadOrStore ensures one winner.
346+
actual, _ := rs.resourcesClients.LoadOrStore(subscriptionId, client)
347+
return actual.(*armresources.Client), nil
334348
}
335349

336350
func (rs *ResourceService) createResourceGroupClient(
337351
ctx context.Context,
338352
subscriptionId string,
339353
) (*armresources.ResourceGroupsClient, error) {
354+
if cached, ok := rs.resourceGroupClients.Load(subscriptionId); ok {
355+
return cached.(*armresources.ResourceGroupsClient), nil
356+
}
357+
340358
credential, err := rs.credentialProvider.CredentialForSubscription(ctx, subscriptionId)
341359
if err != nil {
342360
return nil, err
@@ -347,7 +365,9 @@ func (rs *ResourceService) createResourceGroupClient(
347365
return nil, fmt.Errorf("creating ResourceGroup client: %w", err)
348366
}
349367

350-
return client, nil
368+
// Benign race: concurrent miss creates an extra client; LoadOrStore ensures one winner.
369+
actual, _ := rs.resourceGroupClients.LoadOrStore(subscriptionId, client)
370+
return actual.(*armresources.ResourceGroupsClient), nil
351371
}
352372

353373
// GroupByResourceGroup creates a map of resources group by their resource group name.

0 commit comments

Comments
 (0)