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 });