|
5 | 5 | "errors" |
6 | 6 | "strings" |
7 | 7 | "testing" |
| 8 | + "time" |
8 | 9 |
|
9 | 10 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" |
10 | 11 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" |
@@ -557,3 +558,192 @@ func TestMergeServiceResults_StubsDoNotMaskTotalFailure(t *testing.T) { |
557 | 558 | assert.Contains(t, err.Error(), "all 4 Azure recommendation services failed") |
558 | 559 | assert.Nil(t, recs) |
559 | 560 | } |
| 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