From 499b110ba712f9c2657e27f0a9d91f2454897d26 Mon Sep 17 00:00:00 2001 From: Joscha Alisch Date: Thu, 3 Sep 2026 22:50:50 +0200 Subject: [PATCH] Batch Projects.Get request for serviceusage batching --- .../resource_google_project_service.go.tmpl | 17 +-- .../resourcemanager/serviceusage_batching.go | 44 +++++++ .../serviceusage_batching_test.go | 123 ++++++++++++++++++ 3 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 mmv1/third_party/terraform/services/resourcemanager/serviceusage_batching_test.go diff --git a/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_service.go.tmpl b/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_service.go.tmpl index 519489f8e120..44d8298400e5 100644 --- a/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_service.go.tmpl +++ b/mmv1/third_party/terraform/services/resourcemanager/resource_google_project_service.go.tmpl @@ -12,7 +12,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - rmClient "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager/client" tpgserviceusage "github.com/hashicorp/terraform-provider-google/google/services/serviceusage" "github.com/hashicorp/terraform-provider-google/google/registry" "github.com/hashicorp/terraform-provider-google/google/tpgresource" @@ -248,10 +247,6 @@ func resourceGoogleProjectServiceCreate(d *schema.ResourceData, meta interface{} func resourceGoogleProjectServiceRead(d *schema.ResourceData, meta interface{}) error { config := meta.(*transport_tpg.Config) - userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) - if err != nil { - return err - } project, err := tpgresource.GetProject(d, config) if err != nil { @@ -260,17 +255,7 @@ func resourceGoogleProjectServiceRead(d *schema.ResourceData, meta interface{}) project = tpgresource.GetResourceNameFromSelfLink(project) // Verify project for services still exists - projectGetCall := rmClient.NewClient(config, userAgent).Projects.Get(project) - if config.UserProjectOverride { - billingProject := project - - // err == nil indicates that the billing_project value was found - if bp, err := tpgresource.GetBillingProject(d, config); err == nil { - billingProject = bp - } - projectGetCall.Header().Add("X-Goog-User-Project", billingProject) - } - p, err := projectGetCall.Do() + p, err := BatchRequestReadProject(project, d, config) if err == nil && p.LifecycleState == "DELETE_REQUESTED" { // Construct a 404 error for transport_tpg.HandleNotFoundError diff --git a/mmv1/third_party/terraform/services/resourcemanager/serviceusage_batching.go b/mmv1/third_party/terraform/services/resourcemanager/serviceusage_batching.go index 0a15e2226a04..6cd216de076d 100644 --- a/mmv1/third_party/terraform/services/resourcemanager/serviceusage_batching.go +++ b/mmv1/third_party/terraform/services/resourcemanager/serviceusage_batching.go @@ -5,15 +5,18 @@ import ( "log" "time" + rmClient "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager/client" "github.com/hashicorp/terraform-provider-google/google/tpgresource" transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "google.golang.org/api/cloudresourcemanager/v1" ) const ( batchKeyTmplServiceUsageEnableServices = "project/%s/services:batchEnable" batchKeyTmplServiceUsageListServices = "project/%s/services" + batchKeyTmplServiceUsageGetProject = "project/%s/get" ) // BatchRequestEnableServices can be used to batch requests to enable services @@ -105,6 +108,37 @@ func BatchRequestReadServices(project string, d *schema.ResourceData, config *tr d.Timeout(schema.TimeoutRead)) } +func BatchRequestReadProject(project string, d *schema.ResourceData, config *transport_tpg.Config) (*cloudresourcemanager.Project, error) { + userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) + if err != nil { + return nil, err + } + + billingProject := project + // err == nil indicates that the billing_project value was found + if bp, err := tpgresource.GetBillingProject(d, config); err == nil { + billingProject = bp + } + + req := &transport_tpg.BatchRequest{ + ResourceName: project, + Body: nil, + // Use empty CombineF since the request is exactly the same no matter how many callers ask for this project. + CombineF: func(body interface{}, toAdd interface{}) (interface{}, error) { return nil, nil }, + SendF: sendGetProject(config, billingProject, userAgent), + DebugId: fmt.Sprintf("Get Project %s", project), + } + + resp, err := config.RequestBatcherServiceUsage.SendRequestWithTimeout( + fmt.Sprintf(batchKeyTmplServiceUsageGetProject, project), + req, + d.Timeout(schema.TimeoutRead)) + if err != nil { + return nil, err + } + return resp.(*cloudresourcemanager.Project), nil +} + func combineServiceUsageServicesBatches(srvsRaw interface{}, toAddRaw interface{}) (interface{}, error) { srvs, ok := srvsRaw.([]string) if !ok { @@ -133,3 +167,13 @@ func sendListServices(config *transport_tpg.Config, billingProject, userAgent st return ListCurrentlyEnabledServices(project, billingProject, userAgent, config, timeout) } } + +func sendGetProject(config *transport_tpg.Config, billingProject, userAgent string) transport_tpg.BatcherSendFunc { + return func(project string, _ interface{}) (interface{}, error) { + projectGetCall := rmClient.NewClient(config, userAgent).Projects.Get(project) + if config.UserProjectOverride { + projectGetCall.Header().Add("X-Goog-User-Project", billingProject) + } + return projectGetCall.Do() + } +} diff --git a/mmv1/third_party/terraform/services/resourcemanager/serviceusage_batching_test.go b/mmv1/third_party/terraform/services/resourcemanager/serviceusage_batching_test.go new file mode 100644 index 000000000000..708296a0f239 --- /dev/null +++ b/mmv1/third_party/terraform/services/resourcemanager/serviceusage_batching_test.go @@ -0,0 +1,123 @@ +package resourcemanager + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" +) + +func newTestConfigForProjectBatching(ts *httptest.Server) *transport_tpg.Config { + ctx := context.Background() + batchingConfig := &transport_tpg.BatchingConfig{ + SendAfter: 100 * time.Millisecond, + EnableBatching: true, + } + return &transport_tpg.Config{ + Context: ctx, + Client: ts.Client(), + CustomEndpoints: map[string]string{ + // Matches resourcemanager's Product.CustomEndpointField. + "resource_manager_custom_endpoint": ts.URL + "/", + }, + BatchingConfig: batchingConfig, + RequestBatcherServiceUsage: transport_tpg.NewRequestBatcher("Service Usage", ctx, batchingConfig), + } +} + +func testProjectServiceResourceData(t *testing.T, project, service string) *schema.ResourceData { + t.Helper() + return schema.TestResourceDataRaw(t, ResourceGoogleProjectService().Schema, map[string]interface{}{ + "project": project, + "service": service, + }) +} + +func TestBatchRequestReadProject_CollapsesConcurrentCallsIntoOneRequest(t *testing.T) { + const project = "my-project" + var requestCount int32 + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requestCount, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"projectId": %q, "lifecycleState": "ACTIVE"}`, project) + })) + defer ts.Close() + + config := newTestConfigForProjectBatching(ts) + + const numCallers = 10 + var wg sync.WaitGroup + wg.Add(numCallers) + errs := make([]error, numCallers) + lifecycleStates := make([]string, numCallers) + + for i := 0; i < numCallers; i++ { + go func(idx int) { + defer wg.Done() + d := testProjectServiceResourceData(t, project, "foo.googleapis.com") + p, err := BatchRequestReadProject(project, d, config) + errs[idx] = err + if err == nil { + lifecycleStates[idx] = p.LifecycleState + } + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Errorf("caller %d: unexpected error: %v", i, err) + } + if lifecycleStates[i] != "ACTIVE" { + t.Errorf("caller %d: expected lifecycleState ACTIVE, got %q", i, lifecycleStates[i]) + } + } + + if got := atomic.LoadInt32(&requestCount); got != 1 { + t.Errorf("expected exactly 1 HTTP request to be sent for %d concurrent callers, got %d", numCallers, got) + } +} + +func TestBatchRequestReadProject_SeparateProjectsAreNotCombined(t *testing.T) { + var requestCount int32 + seenProjects := make(map[string]bool) + var mu sync.Mutex + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requestCount, 1) + mu.Lock() + seenProjects[r.URL.Path] = true + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"lifecycleState": "ACTIVE"}`) + })) + defer ts.Close() + + config := newTestConfigForProjectBatching(ts) + + projects := []string{"project-a", "project-b"} + var wg sync.WaitGroup + wg.Add(len(projects)) + for _, project := range projects { + go func(project string) { + defer wg.Done() + d := testProjectServiceResourceData(t, project, "foo.googleapis.com") + if _, err := BatchRequestReadProject(project, d, config); err != nil { + t.Errorf("project %s: unexpected error: %v", project, err) + } + }(project) + } + wg.Wait() + + if got := atomic.LoadInt32(&requestCount); got != int32(len(projects)) { + t.Errorf("expected %d HTTP requests (one per distinct project), got %d", len(projects), got) + } +}