Skip to content

Commit 0cd29e8

Browse files
committed
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).
1 parent 4956d66 commit 0cd29e8

2 files changed

Lines changed: 187 additions & 4 deletions

File tree

‎providers/azure/recommendations.go‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ import (
2121
"github.com/LeanerCloud/CUDly/providers/azure/services/database"
2222
)
2323

24+
// serviceRecsGetter is the narrow interface satisfied by each per-service
25+
// client (compute, database, cache, cosmosdb). The interface exists solely to
26+
// allow tests to substitute fake implementations; production code uses the
27+
// concrete types via the newXxxClientFn variables below.
28+
type serviceRecsGetter interface {
29+
GetRecommendations(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error)
30+
}
31+
32+
// newComputeClientFn, newDatabaseClientFn, newCacheClientFn, and
33+
// newCosmosDBClientFn default to the real constructors and are overridden in
34+
// tests to inject fakes. The variables are package-level (not fields on the
35+
// adapter) so that the constructor signature stays unchanged and the injection
36+
// is limited to the test package that owns the test binary's address space.
37+
var (
38+
newComputeClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter { return compute.NewClient(cred, sub, region) }
39+
newDatabaseClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter { return database.NewClient(cred, sub, region) }
40+
newCacheClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter { return cache.NewClient(cred, sub, region) }
41+
newCosmosDBClientFn func(azcore.TokenCredential, string, string) serviceRecsGetter = func(cred azcore.TokenCredential, sub, region string) serviceRecsGetter { return cosmosdb.NewClient(cred, sub, region) }
42+
)
43+
2444
// RecommendationsClientAdapter aggregates Azure reservation recommendations across all services.
2545
//
2646
// Invariant: subscriptionID must be non-empty. Downstream converters use it as
@@ -99,31 +119,31 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p
99119
// Compute (VM) recommendations — subscription-wide.
100120
if shouldIncludeService(params, common.ServiceCompute) {
101121
goService(&computeErr, func() {
102-
computeClient := compute.NewClient(r.cred, r.subscriptionID, "")
122+
computeClient := newComputeClientFn(r.cred, r.subscriptionID, "")
103123
computeRecs, computeErr = computeClient.GetRecommendations(gctx, params)
104124
})
105125
}
106126

107127
// Database (SQL) recommendations — subscription-wide.
108128
if shouldIncludeService(params, common.ServiceRelationalDB) {
109129
goService(&dbErr, func() {
110-
dbClient := database.NewClient(r.cred, r.subscriptionID, "")
130+
dbClient := newDatabaseClientFn(r.cred, r.subscriptionID, "")
111131
dbRecs, dbErr = dbClient.GetRecommendations(gctx, params)
112132
})
113133
}
114134

115135
// Cache (Redis) recommendations — subscription-wide.
116136
if shouldIncludeService(params, common.ServiceCache) {
117137
goService(&cacheErr, func() {
118-
cacheClient := cache.NewClient(r.cred, r.subscriptionID, "")
138+
cacheClient := newCacheClientFn(r.cred, r.subscriptionID, "")
119139
cacheRecs, cacheErr = cacheClient.GetRecommendations(gctx, params)
120140
})
121141
}
122142

123143
// CosmosDB (NoSQL) recommendations — subscription-wide.
124144
if shouldIncludeService(params, common.ServiceNoSQL) {
125145
goService(&cosmosErr, func() {
126-
cosmosClient := cosmosdb.NewClient(r.cred, r.subscriptionID, "")
146+
cosmosClient := newCosmosDBClientFn(r.cred, r.subscriptionID, "")
127147
cosmosRecs, cosmosErr = cosmosClient.GetRecommendations(gctx, params)
128148
})
129149
}

‎providers/azure/recommendations_test.go‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package azure
22

33
import (
44
"context"
5+
"errors"
56
"testing"
7+
"time"
68

79
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
810
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
@@ -379,3 +381,164 @@ func TestConvertAdvisorRecommendation_UnknownService(t *testing.T) {
379381
func strPtr(s string) *string {
380382
return &s
381383
}
384+
385+
// fakeServiceClient is a test double for serviceRecsGetter. It sleeps for
386+
// sleepDur to simulate network latency and then returns the fixed recs slice
387+
// (or err when non-nil). sleep is done inside GetRecommendations so that
388+
// mock latency is isolated to the mock body — the test's assert path never
389+
// sleeps (see memory feedback_no_sleep_in_tests).
390+
type fakeServiceClient struct {
391+
sleepDur time.Duration
392+
recs []common.Recommendation
393+
err error
394+
}
395+
396+
func (f *fakeServiceClient) GetRecommendations(ctx context.Context, _ common.RecommendationParams) ([]common.Recommendation, error) {
397+
select {
398+
case <-time.After(f.sleepDur):
399+
case <-ctx.Done():
400+
return nil, ctx.Err()
401+
}
402+
return f.recs, f.err
403+
}
404+
405+
// newFakeFn returns a constructor compatible with the newXxxClientFn signature
406+
// that ignores the credential/subscription/region and always returns fake.
407+
func newFakeFn(fake serviceRecsGetter) func(azcore.TokenCredential, string, string) serviceRecsGetter {
408+
return func(_ azcore.TokenCredential, _, _ string) serviceRecsGetter { return fake }
409+
}
410+
411+
const fakeServiceSleep = 100 * time.Millisecond
412+
413+
// TestGetRecommendations_Parallelism proves that all four service goroutines
414+
// run concurrently: total wall-clock time must be well under 2× per-service
415+
// sleep (i.e. less than 200ms) rather than near 4× (400ms sequential).
416+
// Advisor always calls getAdvisorRecommendations which skips the DI seam, so
417+
// we only count the four injectable services.
418+
func TestGetRecommendations_Parallelism(t *testing.T) {
419+
origCompute := newComputeClientFn
420+
origDatabase := newDatabaseClientFn
421+
origCache := newCacheClientFn
422+
origCosmos := newCosmosDBClientFn
423+
t.Cleanup(func() {
424+
newComputeClientFn = origCompute
425+
newDatabaseClientFn = origDatabase
426+
newCacheClientFn = origCache
427+
newCosmosDBClientFn = origCosmos
428+
})
429+
430+
rec := func(svc common.ServiceType) common.Recommendation {
431+
return common.Recommendation{Provider: common.ProviderAzure, Service: svc}
432+
}
433+
434+
newComputeClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{rec(common.ServiceCompute)}})
435+
newDatabaseClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{rec(common.ServiceRelationalDB)}})
436+
newCacheClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{rec(common.ServiceCache)}})
437+
newCosmosDBClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{rec(common.ServiceNoSQL)}})
438+
439+
adapter := &RecommendationsClientAdapter{
440+
cred: &mockAzureTokenCredential{},
441+
subscriptionID: "sub-parallelism",
442+
}
443+
444+
start := time.Now()
445+
_, err := adapter.GetRecommendations(context.Background(), common.RecommendationParams{})
446+
elapsed := time.Since(start)
447+
448+
require.NoError(t, err)
449+
// With 4 services sleeping 100ms in parallel the wall-clock must be
450+
// substantially less than 2× per-service sleep. We allow 190ms headroom
451+
// for scheduler jitter; if the calls were serial this would take ~400ms.
452+
assert.Less(t, elapsed, 2*fakeServiceSleep,
453+
"expected parallel dispatch: elapsed %v >= 2× per-service sleep %v — services may be running serially",
454+
elapsed, fakeServiceSleep)
455+
}
456+
457+
// TestGetRecommendations_OrderPreservation verifies that the merged slice
458+
// follows the canonical order compute → database → cache → cosmosdb regardless
459+
// of which fake goroutine returns first (staggered sleeps force an
460+
// out-of-start-order completion).
461+
func TestGetRecommendations_OrderPreservation(t *testing.T) {
462+
origCompute := newComputeClientFn
463+
origDatabase := newDatabaseClientFn
464+
origCache := newCacheClientFn
465+
origCosmos := newCosmosDBClientFn
466+
t.Cleanup(func() {
467+
newComputeClientFn = origCompute
468+
newDatabaseClientFn = origDatabase
469+
newCacheClientFn = origCache
470+
newCosmosDBClientFn = origCosmos
471+
})
472+
473+
makeRec := func(svc common.ServiceType) common.Recommendation {
474+
return common.Recommendation{Provider: common.ProviderAzure, Service: svc, ResourceType: string(svc)}
475+
}
476+
477+
// Stagger sleeps so goroutines complete in reverse order: cosmosdb
478+
// finishes first (~10ms), compute last (~40ms). The merged slice must
479+
// still reflect the canonical order.
480+
newComputeClientFn = newFakeFn(&fakeServiceClient{sleepDur: 40 * time.Millisecond, recs: []common.Recommendation{makeRec(common.ServiceCompute)}})
481+
newDatabaseClientFn = newFakeFn(&fakeServiceClient{sleepDur: 30 * time.Millisecond, recs: []common.Recommendation{makeRec(common.ServiceRelationalDB)}})
482+
newCacheClientFn = newFakeFn(&fakeServiceClient{sleepDur: 20 * time.Millisecond, recs: []common.Recommendation{makeRec(common.ServiceCache)}})
483+
newCosmosDBClientFn = newFakeFn(&fakeServiceClient{sleepDur: 10 * time.Millisecond, recs: []common.Recommendation{makeRec(common.ServiceNoSQL)}})
484+
485+
adapter := &RecommendationsClientAdapter{
486+
cred: &mockAzureTokenCredential{},
487+
subscriptionID: "sub-order",
488+
}
489+
490+
recs, err := adapter.GetRecommendations(context.Background(), common.RecommendationParams{})
491+
require.NoError(t, err)
492+
493+
// Advisor runs via getAdvisorRecommendations (no DI seam) and returns
494+
// nothing with a mock credential, so we expect exactly 4 recs.
495+
require.Len(t, recs, 4, "expected one rec per injectable service")
496+
assert.Equal(t, common.ServiceCompute, recs[0].Service, "slot 0 must be compute")
497+
assert.Equal(t, common.ServiceRelationalDB, recs[1].Service, "slot 1 must be database")
498+
assert.Equal(t, common.ServiceCache, recs[2].Service, "slot 2 must be cache")
499+
assert.Equal(t, common.ServiceNoSQL, recs[3].Service, "slot 3 must be cosmosdb")
500+
}
501+
502+
// TestGetRecommendations_ErrorIsolation asserts that a single service error
503+
// does not prevent the other services' results from appearing in the merged
504+
// slice (no sibling cancellation).
505+
func TestGetRecommendations_ErrorIsolation(t *testing.T) {
506+
origCompute := newComputeClientFn
507+
origDatabase := newDatabaseClientFn
508+
origCache := newCacheClientFn
509+
origCosmos := newCosmosDBClientFn
510+
t.Cleanup(func() {
511+
newComputeClientFn = origCompute
512+
newDatabaseClientFn = origDatabase
513+
newCacheClientFn = origCache
514+
newCosmosDBClientFn = origCosmos
515+
})
516+
517+
makeRec := func(svc common.ServiceType) common.Recommendation {
518+
return common.Recommendation{Provider: common.ProviderAzure, Service: svc}
519+
}
520+
521+
// database returns an error; the other three must still contribute recs.
522+
newComputeClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{makeRec(common.ServiceCompute)}})
523+
newDatabaseClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, err: errors.New("db unavailable")})
524+
newCacheClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{makeRec(common.ServiceCache)}})
525+
newCosmosDBClientFn = newFakeFn(&fakeServiceClient{sleepDur: fakeServiceSleep, recs: []common.Recommendation{makeRec(common.ServiceNoSQL)}})
526+
527+
adapter := &RecommendationsClientAdapter{
528+
cred: &mockAzureTokenCredential{},
529+
subscriptionID: "sub-isolation",
530+
}
531+
532+
recs, err := adapter.GetRecommendations(context.Background(), common.RecommendationParams{})
533+
require.NoError(t, err, "a per-service error must not surface as a GetRecommendations error")
534+
require.Len(t, recs, 3, "expected recs from the 3 healthy injectable services")
535+
536+
services := make([]common.ServiceType, len(recs))
537+
for i, r := range recs {
538+
services[i] = r.Service
539+
}
540+
assert.Contains(t, services, common.ServiceCompute, "compute recs must be present despite db error")
541+
assert.Contains(t, services, common.ServiceCache, "cache recs must be present despite db error")
542+
assert.Contains(t, services, common.ServiceNoSQL, "cosmosdb recs must be present despite db error")
543+
assert.NotContains(t, services, common.ServiceRelationalDB, "db recs must be absent when db errors")
544+
}

0 commit comments

Comments
 (0)