Skip to content

Commit 4fa4c64

Browse files
authored
test(azure): concurrency timing test for parallel dispatcher (closes #262) (#873)
* test(azure): DI seam + concurrency timing tests for parallel dispatcher (closes #262) Introduce four package-level newXxxClientFn vars (defaulting to the real compute/database/cache/cosmosdb constructors) so tests can inject fake clients without changing production behaviour. Add three tests in recommendations_test.go: - TestGetRecommendations_Parallelism: 4 fakes sleep 100ms each; asserts wall-clock < 200ms, proving concurrent dispatch (serial would be ~400ms). - TestGetRecommendations_OrderPreservation: staggered sleeps force out-of-order completion; asserts merged slice is compute->db->cache->cosmos. - TestGetRecommendations_ErrorIsolation: one fake errors; asserts the other three services still contribute recs (no sibling cancellation). * fix(test/azure): inject savingsplans+advisor to make timing test hermetic TestGetRecommendations_Parallelism was failing (~300-700ms wall clock vs 200ms threshold) because the savingsplans and advisor goroutines used real constructors that made ARM network calls, adding unbounded latency outside the four fakeServiceClient mocks. Add newSavingsPlansClientFn package-level var (parallel to the other four) and a getAdvisorRecsFn field on RecommendationsClientAdapter (defaulting to r.getAdvisorRecommendations, with a nil guard for struct-literal adapters in existing tests). Wire newSavingsPlansClientFn in GetRecommendations to replace the direct savingsplans.NewClient call. In the three concurrency tests (Parallelism, OrderPreservation, ErrorIsolation) inject noopAdvisorFn and a zero-sleep fakeServiceClient for savingsplans so all latency comes exclusively from the four injectable mocks. Tests are now fully hermetic: no real ARM calls, deterministic rec count, pass consistently under -race. 699 azure provider tests pass; 0 lint issues in touched files.
1 parent 7465a9e commit 4fa4c64

2 files changed

Lines changed: 250 additions & 10 deletions

File tree

‎providers/azure/recommendations.go‎

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,43 @@ import (
2222
"github.com/LeanerCloud/CUDly/providers/azure/services/savingsplans"
2323
)
2424

25+
// serviceRecsGetter is the narrow interface satisfied by each per-service
26+
// client (compute, database, cache, cosmosdb). The interface exists solely to
27+
// allow tests to substitute fake implementations; production code uses the
28+
// concrete types via the newXxxClientFn variables below.
29+
type serviceRecsGetter interface {
30+
GetRecommendations(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error)
31+
}
32+
33+
// newComputeClientFn, newDatabaseClientFn, newCacheClientFn,
34+
// newCosmosDBClientFn, and newSavingsPlansClientFn default to the real
35+
// constructors and are overridden in tests to inject fakes. The variables are
36+
// package-level (not fields on the adapter) so that the constructor signature
37+
// stays unchanged and the injection is limited to the test package that owns
38+
// the test binary's address space.
39+
//
40+
// getAdvisorRecsFn wraps r.getAdvisorRecommendations; it is a field rather
41+
// than a package-level var so that injection is scoped to the adapter
42+
// instance and avoids shared-state issues when tests run the advisor path
43+
// concurrently. Tests that do not need real ARM calls set it to noopAdvisorFn.
44+
var (
45+
newComputeClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter {
46+
return compute.NewClient(cred, sub, region)
47+
}
48+
newDatabaseClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter {
49+
return database.NewClient(cred, sub, region)
50+
}
51+
newCacheClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter {
52+
return cache.NewClient(cred, sub, region)
53+
}
54+
newCosmosDBClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter {
55+
return cosmosdb.NewClient(cred, sub, region)
56+
}
57+
newSavingsPlansClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter {
58+
return savingsplans.NewClient(cred, sub, region)
59+
}
60+
)
61+
2562
// RecommendationsClientAdapter aggregates Azure reservation recommendations across all services.
2663
//
2764
// Invariant: subscriptionID must be non-empty. Downstream converters use it as
@@ -31,9 +68,13 @@ import (
3168
// path is NewRecommendationsClientAdapter; direct struct literals bypass the
3269
// invariant check and should be confined to tests that deliberately exercise
3370
// the unvalidated shape.
71+
//
72+
// getAdvisorRecsFn defaults to r.getAdvisorRecommendations and may be
73+
// overridden per-instance in tests to avoid real ARM network calls.
3474
type RecommendationsClientAdapter struct {
35-
cred azcore.TokenCredential
36-
subscriptionID string
75+
cred azcore.TokenCredential
76+
subscriptionID string
77+
getAdvisorRecsFn func(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error)
3778
}
3879

3980
// NewRecommendationsClientAdapter builds a RecommendationsClientAdapter with
@@ -44,10 +85,12 @@ func NewRecommendationsClientAdapter(cred azcore.TokenCredential, subscriptionID
4485
if subscriptionID == "" {
4586
return nil, fmt.Errorf("azure recommendations: subscriptionID is required")
4687
}
47-
return &RecommendationsClientAdapter{
88+
r := &RecommendationsClientAdapter{
4889
cred: cred,
4990
subscriptionID: subscriptionID,
50-
}, nil
91+
}
92+
r.getAdvisorRecsFn = r.getAdvisorRecommendations
93+
return r, nil
5194
}
5295

5396
// GetRecommendations retrieves all Azure reservation recommendations across services.
@@ -109,31 +152,31 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p
109152
// Compute (VM) recommendations — subscription-wide.
110153
if includeCompute {
111154
goService(&computeErr, func() {
112-
computeClient := compute.NewClient(r.cred, r.subscriptionID, "")
155+
computeClient := newComputeClientFn(r.cred, r.subscriptionID, "")
113156
computeRecs, computeErr = computeClient.GetRecommendations(gctx, params)
114157
})
115158
}
116159

117160
// Database (SQL) recommendations — subscription-wide.
118161
if includeDB {
119162
goService(&dbErr, func() {
120-
dbClient := database.NewClient(r.cred, r.subscriptionID, "")
163+
dbClient := newDatabaseClientFn(r.cred, r.subscriptionID, "")
121164
dbRecs, dbErr = dbClient.GetRecommendations(gctx, params)
122165
})
123166
}
124167

125168
// Cache (Redis) recommendations — subscription-wide.
126169
if includeCache {
127170
goService(&cacheErr, func() {
128-
cacheClient := cache.NewClient(r.cred, r.subscriptionID, "")
171+
cacheClient := newCacheClientFn(r.cred, r.subscriptionID, "")
129172
cacheRecs, cacheErr = cacheClient.GetRecommendations(gctx, params)
130173
})
131174
}
132175

133176
// CosmosDB (NoSQL) recommendations — subscription-wide.
134177
if includeCosmos {
135178
goService(&cosmosErr, func() {
136-
cosmosClient := cosmosdb.NewClient(r.cred, r.subscriptionID, "")
179+
cosmosClient := newCosmosDBClientFn(r.cred, r.subscriptionID, "")
137180
cosmosRecs, cosmosErr = cosmosClient.GetRecommendations(gctx, params)
138181
})
139182
}
@@ -145,16 +188,23 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p
145188
// a scheduler change.
146189
if includeSP {
147190
goService(&spErr, func() {
148-
spClient := savingsplans.NewClient(r.cred, r.subscriptionID, "")
191+
spClient := newSavingsPlansClientFn(r.cred, r.subscriptionID, "")
149192
spRecs, spErr = spClient.GetRecommendations(gctx, params)
150193
})
151194
}
152195

153196
// Azure Advisor adds cross-cutting cost recommendations independent of the
154197
// per-service Reservation API. Failures here are non-fatal — the per-service
155198
// results above are still useful on their own.
199+
//
200+
// getAdvisorRecsFn defaults to r.getAdvisorRecommendations and may be
201+
// replaced per-adapter in tests to avoid real ARM network calls.
202+
advisorFn := r.getAdvisorRecsFn
203+
if advisorFn == nil {
204+
advisorFn = r.getAdvisorRecommendations
205+
}
156206
goService(&advisorErr, func() {
157-
advisorRecs, advisorErr = r.getAdvisorRecommendations(gctx, params)
207+
advisorRecs, advisorErr = advisorFn(gctx, params)
158208
})
159209

160210
// Wait for all goroutines. g.Wait() always returns nil because every

‎providers/azure/recommendations_test.go‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"strings"
77
"testing"
8+
"time"
89

910
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1011
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
@@ -557,3 +558,192 @@ func TestMergeServiceResults_StubsDoNotMaskTotalFailure(t *testing.T) {
557558
assert.Contains(t, err.Error(), "all 4 Azure recommendation services failed")
558559
assert.Nil(t, recs)
559560
}
561+
562+
// fakeServiceClient is a test double for serviceRecsGetter. It sleeps for
563+
// sleepDur to simulate network latency and then returns the fixed recs slice
564+
// (or err when non-nil). sleep is done inside GetRecommendations so that
565+
// mock latency is isolated to the mock body — the test's assert path never
566+
// sleeps (see memory feedback_no_sleep_in_tests).
567+
type fakeServiceClient struct {
568+
sleepDur time.Duration
569+
recs []common.Recommendation
570+
err error
571+
}
572+
573+
func (f *fakeServiceClient) GetRecommendations(ctx context.Context, _ common.RecommendationParams) ([]common.Recommendation, error) {
574+
select {
575+
case <-time.After(f.sleepDur):
576+
case <-ctx.Done():
577+
return nil, ctx.Err()
578+
}
579+
return f.recs, f.err
580+
}
581+
582+
// newFakeFn returns a constructor compatible with the newXxxClientFn signature
583+
// that ignores the credential/subscription/region and always returns fake.
584+
func newFakeFn(fake serviceRecsGetter) func(azcore.TokenCredential, string, string) serviceRecsGetter {
585+
return func(_ azcore.TokenCredential, _, _ string) serviceRecsGetter { return fake }
586+
}
587+
588+
// noopAdvisorFn is a getAdvisorRecsFn replacement that returns immediately
589+
// with zero results, used in timing/isolation tests to keep all latency inside
590+
// the injectable fakeServiceClient mocks.
591+
func noopAdvisorFn(_ context.Context, _ common.RecommendationParams) ([]common.Recommendation, error) {
592+
return nil, nil
593+
}
594+
595+
const fakeServiceSleep = 100 * time.Millisecond
596+
597+
// TestGetRecommendations_Parallelism proves that all four service goroutines
598+
// run concurrently: total wall-clock time must be well under 2x per-service
599+
// sleep (i.e. less than 200ms) rather than near 4x (400ms sequential).
600+
//
601+
// savingsplans and advisor are injected as instant no-ops so that all latency
602+
// comes from the four fakeServiceClient mocks; without this, both paths make
603+
// real ARM network calls whose RTT dwarfs the 100ms threshold.
604+
func TestGetRecommendations_Parallelism(t *testing.T) {
605+
origCompute := newComputeClientFn
606+
origDatabase := newDatabaseClientFn
607+
origCache := newCacheClientFn
608+
origCosmos := newCosmosDBClientFn
609+
origSP := newSavingsPlansClientFn
610+
t.Cleanup(func() {
611+
newComputeClientFn = origCompute
612+
newDatabaseClientFn = origDatabase
613+
newCacheClientFn = origCache
614+
newCosmosDBClientFn = origCosmos
615+
newSavingsPlansClientFn = origSP
616+
})
617+
618+
rec := func(svc common.ServiceType) common.Recommendation {
619+
return common.Recommendation{Provider: common.ProviderAzure, Service: svc}
620+
}
621+
622+
noopFake := newFakeFn(&fakeServiceClient{})
623+
newComputeClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{rec(common.ServiceCompute)}})
624+
newDatabaseClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{rec(common.ServiceRelationalDB)}})
625+
newCacheClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{rec(common.ServiceCache)}})
626+
newCosmosDBClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{rec(common.ServiceNoSQL)}})
627+
newSavingsPlansClientFn = noopFake
628+
629+
adapter := &RecommendationsClientAdapter{
630+
cred: &mockAzureTokenCredential{},
631+
subscriptionID: "sub-parallelism",
632+
getAdvisorRecsFn: noopAdvisorFn,
633+
}
634+
635+
start := time.Now()
636+
_, err := adapter.GetRecommendations(context.Background(), common.RecommendationParams{})
637+
elapsed := time.Since(start)
638+
639+
require.NoError(t, err)
640+
// With 4 services sleeping 100ms in parallel the wall-clock must be
641+
// substantially less than 2x per-service sleep. We allow 190ms headroom
642+
// for scheduler jitter; if the calls were serial this would take ~400ms.
643+
assert.Less(t, elapsed, 2*fakeServiceSleep,
644+
"expected parallel dispatch: elapsed %v >= 2x per-service sleep %v -- services may be running serially",
645+
elapsed, fakeServiceSleep)
646+
}
647+
648+
// TestGetRecommendations_OrderPreservation verifies that the merged slice
649+
// follows the canonical order compute -> database -> cache -> cosmosdb regardless
650+
// of which fake goroutine returns first (staggered sleeps force an
651+
// out-of-start-order completion).
652+
//
653+
// savingsplans and advisor are injected as instant no-ops so the test is
654+
// fully hermetic: no real ARM calls, deterministic rec count.
655+
func TestGetRecommendations_OrderPreservation(t *testing.T) {
656+
origCompute := newComputeClientFn
657+
origDatabase := newDatabaseClientFn
658+
origCache := newCacheClientFn
659+
origCosmos := newCosmosDBClientFn
660+
origSP := newSavingsPlansClientFn
661+
t.Cleanup(func() {
662+
newComputeClientFn = origCompute
663+
newDatabaseClientFn = origDatabase
664+
newCacheClientFn = origCache
665+
newCosmosDBClientFn = origCosmos
666+
newSavingsPlansClientFn = origSP
667+
})
668+
669+
makeRec := func(svc common.ServiceType) common.Recommendation {
670+
return common.Recommendation{Provider: common.ProviderAzure, Service: svc, ResourceType: string(svc)}
671+
}
672+
673+
// Stagger sleeps so goroutines complete in reverse order: cosmosdb
674+
// finishes first (~10ms), compute last (~40ms). The merged slice must
675+
// still reflect the canonical order.
676+
newComputeClientFn = newFakeFn(&fakeServiceClient{sleepDur: 40 * time.Millisecond, recs: []common.Recommendation{makeRec(common.ServiceCompute)}})
677+
newDatabaseClientFn = newFakeFn(&fakeServiceClient{sleepDur: 30 * time.Millisecond, recs: []common.Recommendation{makeRec(common.ServiceRelationalDB)}})
678+
newCacheClientFn = newFakeFn(&fakeServiceClient{sleepDur: 20 * time.Millisecond, recs: []common.Recommendation{makeRec(common.ServiceCache)}})
679+
newCosmosDBClientFn = newFakeFn(&fakeServiceClient{sleepDur: 10 * time.Millisecond, recs: []common.Recommendation{makeRec(common.ServiceNoSQL)}})
680+
newSavingsPlansClientFn = newFakeFn(&fakeServiceClient{})
681+
682+
adapter := &RecommendationsClientAdapter{
683+
cred: &mockAzureTokenCredential{},
684+
subscriptionID: "sub-order",
685+
getAdvisorRecsFn: noopAdvisorFn,
686+
}
687+
688+
recs, err := adapter.GetRecommendations(context.Background(), common.RecommendationParams{})
689+
require.NoError(t, err)
690+
691+
// savingsplans and advisor are no-ops; we expect exactly 4 recs (one per
692+
// injectable service) in canonical order.
693+
require.Len(t, recs, 4, "expected one rec per injectable service")
694+
assert.Equal(t, common.ServiceCompute, recs[0].Service, "slot 0 must be compute")
695+
assert.Equal(t, common.ServiceRelationalDB, recs[1].Service, "slot 1 must be database")
696+
assert.Equal(t, common.ServiceCache, recs[2].Service, "slot 2 must be cache")
697+
assert.Equal(t, common.ServiceNoSQL, recs[3].Service, "slot 3 must be cosmosdb")
698+
}
699+
700+
// TestGetRecommendations_ErrorIsolation asserts that a single service error
701+
// does not prevent the other services' results from appearing in the merged
702+
// slice (no sibling cancellation).
703+
//
704+
// savingsplans and advisor are injected as instant no-ops so the rec count is
705+
// fully deterministic regardless of network availability.
706+
func TestGetRecommendations_ErrorIsolation(t *testing.T) {
707+
origCompute := newComputeClientFn
708+
origDatabase := newDatabaseClientFn
709+
origCache := newCacheClientFn
710+
origCosmos := newCosmosDBClientFn
711+
origSP := newSavingsPlansClientFn
712+
t.Cleanup(func() {
713+
newComputeClientFn = origCompute
714+
newDatabaseClientFn = origDatabase
715+
newCacheClientFn = origCache
716+
newCosmosDBClientFn = origCosmos
717+
newSavingsPlansClientFn = origSP
718+
})
719+
720+
makeRec := func(svc common.ServiceType) common.Recommendation {
721+
return common.Recommendation{Provider: common.ProviderAzure, Service: svc}
722+
}
723+
724+
// database returns an error; the other three must still contribute recs.
725+
newComputeClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{makeRec(common.ServiceCompute)}})
726+
newDatabaseClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, err: errors.New("db unavailable")})
727+
newCacheClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{makeRec(common.ServiceCache)}})
728+
newCosmosDBClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{makeRec(common.ServiceNoSQL)}})
729+
newSavingsPlansClientFn = newFakeFn(&fakeServiceClient{})
730+
731+
adapter := &RecommendationsClientAdapter{
732+
cred: &mockAzureTokenCredential{},
733+
subscriptionID: "sub-isolation",
734+
getAdvisorRecsFn: noopAdvisorFn,
735+
}
736+
737+
recs, err := adapter.GetRecommendations(context.Background(), common.RecommendationParams{})
738+
require.NoError(t, err, "a per-service error must not surface as a GetRecommendations error")
739+
require.Len(t, recs, 3, "expected recs from the 3 healthy injectable services")
740+
741+
services := make([]common.ServiceType, len(recs))
742+
for i, r := range recs {
743+
services[i] = r.Service
744+
}
745+
assert.Contains(t, services, common.ServiceCompute, "compute recs must be present despite db error")
746+
assert.Contains(t, services, common.ServiceCache, "cache recs must be present despite db error")
747+
assert.Contains(t, services, common.ServiceNoSQL, "cosmosdb recs must be present despite db error")
748+
assert.NotContains(t, services, common.ServiceRelationalDB, "db recs must be absent when db errors")
749+
}

0 commit comments

Comments
 (0)