go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/proto_utils/batched_builds_client.test.ts (about)

     1  // Copyright 2024 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  /* eslint-disable new-cap */
    16  
    17  import { Build } from '@/proto/go.chromium.org/luci/buildbucket/proto/build.pb';
    18  import {
    19    BatchRequest,
    20    BatchRequest_Request,
    21    BatchResponse,
    22    BatchResponse_Response,
    23    BuildsClientImpl,
    24    GetBuildRequest,
    25    GetBuildStatusRequest,
    26    SearchBuildsRequest,
    27    SearchBuildsResponse,
    28  } from '@/proto/go.chromium.org/luci/buildbucket/proto/builds_service.pb';
    29  
    30  import { BatchedBuildsClientImpl } from './batched_builds_client';
    31  
    32  const getBuildReq = GetBuildRequest.fromPartial({
    33    id: '1234',
    34  });
    35  const getBuildRes = Build.fromPartial({
    36    id: '1234',
    37  });
    38  
    39  const searchBuildsReq = SearchBuildsRequest.fromPartial({
    40    predicate: {
    41      builder: {
    42        project: 'proj',
    43        bucket: 'bucket',
    44        builder: 'builder1',
    45      },
    46    },
    47  });
    48  const searchBuildsRes = SearchBuildsResponse.fromPartial({
    49    builds: Object.freeze([
    50      {
    51        id: '2345',
    52      },
    53    ]),
    54  });
    55  
    56  const getBuildStatusReq = GetBuildStatusRequest.fromPartial({
    57    id: '3456',
    58  });
    59  const getBuildStatusRes = Build.fromPartial({
    60    id: '3456',
    61  });
    62  
    63  const batchReq = BatchRequest.fromPartial({
    64    requests: Object.freeze([
    65      BatchRequest_Request.fromPartial({
    66        getBuild: getBuildReq,
    67      }),
    68      BatchRequest_Request.fromPartial({
    69        searchBuilds: searchBuildsReq,
    70      }),
    71      BatchRequest_Request.fromPartial({
    72        getBuildStatus: getBuildStatusReq,
    73      }),
    74    ]),
    75  });
    76  const batchRes = BatchResponse.fromPartial({
    77    responses: Object.freeze([
    78      { getBuild: getBuildRes },
    79      { searchBuilds: searchBuildsRes },
    80      { getBuildStatus: getBuildStatusRes },
    81    ]),
    82  });
    83  
    84  describe('BatchedBuildsClientImpl', () => {
    85    let batchSpy: jest.SpiedFunction<BuildsClientImpl['Batch']>;
    86    let getBuildSpy: jest.SpiedFunction<BuildsClientImpl['GetBuild']>;
    87    let searchBuildsSpy: jest.SpiedFunction<BuildsClientImpl['SearchBuilds']>;
    88    let getBuildStatusSpy: jest.SpiedFunction<BuildsClientImpl['GetBuildStatus']>;
    89  
    90    beforeEach(() => {
    91      jest.useFakeTimers();
    92      batchSpy = jest
    93        .spyOn(BuildsClientImpl.prototype, 'Batch')
    94        .mockImplementation(async (batchReq) => {
    95          return BatchResponse.fromPartial({
    96            responses: Object.freeze(
    97              batchReq.requests.map((req) => {
    98                if (req.getBuild) {
    99                  return BatchResponse_Response.fromPartial({
   100                    getBuild: getBuildRes,
   101                  });
   102                }
   103                if (req.searchBuilds) {
   104                  return BatchResponse_Response.fromPartial({
   105                    searchBuilds: searchBuildsRes,
   106                  });
   107                }
   108                if (req.getBuildStatus) {
   109                  return BatchResponse_Response.fromPartial({
   110                    getBuildStatus: getBuildStatusRes,
   111                  });
   112                }
   113                throw Error('unimplemented');
   114              }),
   115            ),
   116          });
   117        });
   118      getBuildSpy = jest.spyOn(BuildsClientImpl.prototype, 'GetBuild');
   119      searchBuildsSpy = jest.spyOn(BuildsClientImpl.prototype, 'SearchBuilds');
   120      getBuildStatusSpy = jest.spyOn(
   121        BuildsClientImpl.prototype,
   122        'GetBuildStatus',
   123      );
   124    });
   125  
   126    afterEach(() => {
   127      jest.useRealTimers();
   128      batchSpy.mockReset();
   129      getBuildSpy.mockReset();
   130      searchBuildsSpy.mockReset();
   131      getBuildStatusSpy.mockReset();
   132    });
   133  
   134    it('can batch eligible requests together', async () => {
   135      const client = new BatchedBuildsClientImpl({ request: jest.fn() });
   136  
   137      const getBuildCall = client.GetBuild(getBuildReq);
   138      const searchBuildsCall = client.SearchBuilds(searchBuildsReq);
   139      const getBuildStatusCall = client.GetBuildStatus(getBuildStatusReq);
   140      const batchCall = client.Batch(batchReq);
   141  
   142      await jest.advanceTimersToNextTimerAsync();
   143      // The batch function should be called only once.
   144      expect(batchSpy).toHaveBeenCalledTimes(1);
   145      expect(batchSpy).toHaveBeenCalledWith(
   146        BatchRequest.fromPartial({
   147          requests: Object.freeze([
   148            {
   149              getBuild: getBuildReq,
   150            },
   151            {
   152              searchBuilds: searchBuildsReq,
   153            },
   154            {
   155              getBuildStatus: getBuildStatusReq,
   156            },
   157            // The following are merged from a "Batch" call.
   158            {
   159              getBuild: getBuildReq,
   160            },
   161            {
   162              searchBuilds: searchBuildsReq,
   163            },
   164            {
   165              getBuildStatus: getBuildStatusReq,
   166            },
   167          ]),
   168        }),
   169      );
   170  
   171      // The responses should be just like regular calls.
   172      expect(await getBuildCall).toEqual(getBuildRes);
   173      expect(await searchBuildsCall).toEqual(searchBuildsRes);
   174      expect(await getBuildStatusCall).toEqual(getBuildStatusRes);
   175      expect(await batchCall).toEqual(batchRes);
   176  
   177      // Original RPCs not called.
   178      expect(getBuildSpy).not.toHaveBeenCalled();
   179      expect(searchBuildsSpy).not.toHaveBeenCalled();
   180      expect(getBuildStatusSpy).not.toHaveBeenCalled();
   181    });
   182  
   183    it('can handle over batching', async () => {
   184      const client = new BatchedBuildsClientImpl({ request: jest.fn() });
   185  
   186      const getBuildCalls = Array(30)
   187        .fill(0)
   188        .map(() => client.GetBuild(getBuildReq));
   189      const batchCalls = Array(30)
   190        .fill(0)
   191        .map(() => client.Batch(batchReq));
   192      const getBuildStatusCalls = Array(30)
   193        .fill(0)
   194        .map(() => client.GetBuildStatus(getBuildStatusReq));
   195      const otherBatchCalls = Array(30)
   196        .fill(0)
   197        .map(() => client.Batch(batchReq));
   198  
   199      await jest.advanceTimersToNextTimerAsync();
   200      // The batch function should be called more than once.
   201      expect(batchSpy).toHaveBeenCalledTimes(2);
   202  
   203      // The responses should be just like regular calls.
   204      expect(await Promise.all(getBuildCalls)).toEqual(
   205        Array(30).fill(getBuildRes),
   206      );
   207      expect(await Promise.all(batchCalls)).toEqual(Array(30).fill(batchRes));
   208      expect(await Promise.all(getBuildStatusCalls)).toEqual(
   209        Array(30).fill(getBuildStatusRes),
   210      );
   211      expect(await Promise.all(otherBatchCalls)).toEqual(
   212        Array(30).fill(batchRes),
   213      );
   214    });
   215  });