go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/services/luci_analysis/luci_analysis.ts (about) 1 // Copyright 2022 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 import stableStringify from 'fast-json-stable-stringify'; 16 17 import { batched, BatchOption } from '@/generic_libs/tools/batched_fn'; 18 import { cached, CacheOption } from '@/generic_libs/tools/cached_fn'; 19 import { PrpcClientExt } from '@/generic_libs/tools/prpc_client_ext'; 20 21 export interface Variant { 22 readonly def: { [key: string]: string }; 23 } 24 25 export type VariantPredicate = 26 | { readonly equals: Variant } 27 | { readonly contains: Variant } 28 | { readonly hashEquals: string }; 29 30 export const enum SubmittedFilter { 31 SUBMITTED_FILTER_UNSPECIFIED = 'SUBMITTED_FILTER_UNSPECIFIED', 32 ONLY_SUBMITTED = 'ONLY_SUBMITTED', 33 ONLY_UNSUBMITTED = 'ONLY_UNSUBMITTED', 34 } 35 36 export enum TestVerdictStatus { 37 TEST_VERDICT_STATUS_UNSPECIFIED = 'TEST_VERDICT_STATUS_UNSPECIFIED', 38 UNEXPECTED = 'UNEXPECTED', 39 UNEXPECTEDLY_SKIPPED = 'UNEXPECTEDLY_SKIPPED', 40 FLAKY = 'FLAKY', 41 EXONERATED = 'EXONERATED', 42 EXPECTED = 'EXPECTED', 43 } 44 45 export interface TimeRange { 46 readonly earliest?: string; 47 readonly latest?: string; 48 } 49 50 export interface Changelist { 51 readonly host: string; 52 readonly change: string; 53 readonly patchset: number; 54 readonly ownerKind: ChangelistOwnerKind; 55 } 56 57 export const enum ChangelistOwnerKind { 58 Unspecified = 'CHANGELIST_OWNER_UNSPECIFIED', 59 Human = 'HUMAN', 60 Automation = 'AUTOMATION', 61 } 62 63 export interface TestVerdictPredicate { 64 readonly subRealm?: string; 65 readonly variantPredicate?: VariantPredicate; 66 readonly submittedFilter?: SubmittedFilter; 67 readonly partitionTimeRange?: TimeRange; 68 } 69 70 export interface QueryTestHistoryRequest { 71 readonly project: string; 72 readonly testId: string; 73 readonly predicate: TestVerdictPredicate; 74 readonly pageSize?: number; 75 readonly pageToken?: string; 76 } 77 78 export interface TestVerdict { 79 readonly testId: string; 80 readonly variantHash: string; 81 readonly invocationId: string; 82 readonly status: TestVerdictStatus; 83 readonly partitionTime: string; 84 readonly passedAvgDuration?: string; 85 readonly changelists?: readonly Changelist[]; 86 } 87 88 export interface QueryTestHistoryResponse { 89 readonly verdicts?: readonly TestVerdict[]; 90 readonly nextPageToken?: string; 91 } 92 93 export interface QueryTestHistoryStatsRequest { 94 readonly project: string; 95 readonly testId: string; 96 readonly predicate: TestVerdictPredicate; 97 readonly pageSize?: number; 98 readonly pageToken?: string; 99 } 100 101 export interface QueryTestHistoryStatsResponseGroup { 102 readonly partitionTime: string; 103 readonly variantHash: string; 104 readonly unexpectedCount?: number; 105 readonly unexpectedlySkippedCount?: number; 106 readonly flakyCount?: number; 107 readonly exoneratedCount?: number; 108 readonly expectedCount?: number; 109 readonly passedAvgDuration?: string; 110 } 111 112 export interface QueryTestHistoryStatsResponse { 113 readonly groups?: readonly QueryTestHistoryStatsResponseGroup[]; 114 readonly nextPageToken?: string; 115 } 116 117 export interface QueryVariantsRequest { 118 readonly project: string; 119 readonly testId: string; 120 readonly subRealm?: string; 121 readonly variantPredicate?: VariantPredicate; 122 readonly pageSize?: number; 123 readonly pageToken?: string; 124 } 125 126 export interface QueryVariantsResponseVariantInfo { 127 readonly variantHash: string; 128 readonly variant?: Variant; 129 } 130 131 export interface QueryVariantsResponse { 132 readonly variants?: readonly QueryVariantsResponseVariantInfo[]; 133 readonly nextPageToken?: string; 134 } 135 136 export interface TestVerdictBundle { 137 readonly verdict: TestVerdict; 138 readonly variant: Variant; 139 } 140 141 export interface FailureReason { 142 readonly primaryErrorMessage: string; 143 } 144 145 export interface QueryTestsRequest { 146 readonly project: string; 147 readonly testIdSubstring: string; 148 readonly subRealm?: string; 149 readonly pageSize?: number; 150 readonly pageToken?: string; 151 } 152 153 export interface QueryTestsResponse { 154 readonly testIds?: string[]; 155 readonly nextPageToken?: string; 156 } 157 158 export interface ClusterRequest { 159 readonly project: string; 160 readonly testResults: ReadonlyArray<{ 161 readonly requestTag?: string; 162 readonly testId: string; 163 readonly failureReason?: FailureReason; 164 }>; 165 } 166 167 export interface Cluster { 168 readonly clusterId: ClusterId; 169 readonly bug?: AssociatedBug; 170 } 171 172 export interface ClusterResponse { 173 readonly clusteredTestResults: ReadonlyArray<{ 174 readonly requestTag?: string; 175 readonly clusters: readonly Cluster[]; 176 }>; 177 readonly clusteringVersion: ClusteringVersion; 178 } 179 180 export interface ClusteringVersion { 181 readonly algorithmsVersion: string; 182 readonly rulesVersion: string; 183 readonly configVersion: string; 184 } 185 186 export interface ClusterId { 187 readonly algorithm: string; 188 readonly id: string; 189 } 190 191 export interface AssociatedBug { 192 readonly system: string; 193 readonly id: string; 194 readonly linkText: string; 195 readonly url: string; 196 } 197 198 export class TestHistoryService { 199 static readonly SERVICE = 'luci.analysis.v1.TestHistory'; 200 201 private readonly cachedCallFn: ( 202 opt: CacheOption, 203 method: string, 204 message: object, 205 ) => Promise<unknown>; 206 207 constructor(client: PrpcClientExt) { 208 this.cachedCallFn = cached( 209 (method: string, message: object) => 210 client.call(TestHistoryService.SERVICE, method, message), 211 { 212 key: (method, message) => `${method}-${stableStringify(message)}`, 213 }, 214 ); 215 } 216 217 async query( 218 req: QueryTestHistoryRequest, 219 cacheOpt: CacheOption = {}, 220 ): Promise<QueryTestHistoryResponse> { 221 return (await this.cachedCallFn( 222 cacheOpt, 223 'Query', 224 req, 225 )) as QueryTestHistoryResponse; 226 } 227 228 async queryStats( 229 req: QueryTestHistoryStatsRequest, 230 cacheOpt: CacheOption = {}, 231 ): Promise<QueryTestHistoryStatsResponse> { 232 return (await this.cachedCallFn( 233 cacheOpt, 234 'QueryStats', 235 req, 236 )) as QueryTestHistoryStatsResponse; 237 } 238 239 async queryVariants( 240 req: QueryVariantsRequest, 241 cacheOpt: CacheOption = {}, 242 ): Promise<QueryVariantsResponse> { 243 return (await this.cachedCallFn( 244 cacheOpt, 245 'QueryVariants', 246 req, 247 )) as QueryVariantsResponse; 248 } 249 250 async queryTests( 251 req: QueryTestsRequest, 252 cacheOpt: CacheOption = {}, 253 ): Promise<QueryTestsResponse> { 254 return (await this.cachedCallFn( 255 cacheOpt, 256 'QueryTests', 257 req, 258 )) as QueryTestsResponse; 259 } 260 } 261 262 export class ClustersService { 263 static readonly SERVICE = 'luci.analysis.v1.Clusters'; 264 265 private readonly cachedBatchedCluster: ( 266 cacheOpt: CacheOption, 267 batchOpt: BatchOption, 268 req: ClusterRequest, 269 ) => Promise<ClusterResponse>; 270 271 constructor(client: PrpcClientExt) { 272 const CLUSTER_BATCH_LIMIT = 1000; 273 274 const batchedCluster = batched<[ClusterRequest], ClusterResponse>({ 275 fn: (req: ClusterRequest) => 276 client.call(ClustersService.SERVICE, 'Cluster', req), 277 combineParamSets: ([req1], [req2]) => { 278 const canCombine = 279 req1.testResults.length + req2.testResults.length <= 280 CLUSTER_BATCH_LIMIT && req1.project === req2.project; 281 if (!canCombine) { 282 return { ok: false } as ResultErr<void>; 283 } 284 return { 285 ok: true, 286 value: [ 287 { 288 project: req1.project, 289 testResults: [...req1.testResults, ...req2.testResults], 290 }, 291 ] as [ClusterRequest], 292 }; 293 }, 294 splitReturn: (paramSets, ret) => { 295 let pivot = 0; 296 const splitRets: ClusterResponse[] = []; 297 for (const [req] of paramSets) { 298 splitRets.push({ 299 clusteringVersion: ret.clusteringVersion, 300 clusteredTestResults: ret.clusteredTestResults.slice( 301 pivot, 302 pivot + req.testResults.length, 303 ), 304 }); 305 pivot += req.testResults.length; 306 } 307 308 return splitRets; 309 }, 310 }); 311 312 this.cachedBatchedCluster = cached( 313 (batchOpt: BatchOption, req: ClusterRequest) => 314 batchedCluster(batchOpt, req), 315 { 316 key: (_batchOpt, req) => stableStringify(req), 317 }, 318 ); 319 } 320 321 async cluster( 322 req: ClusterRequest, 323 cacheOpt: CacheOption = {}, 324 batchOpt: BatchOption = {}, 325 ): Promise<ClusterResponse> { 326 return (await this.cachedBatchedCluster( 327 cacheOpt, 328 batchOpt, 329 req, 330 )) as ClusterResponse; 331 } 332 } 333 334 /** 335 * Construct a link to a luci-analysis rule page. 336 */ 337 export function makeRuleLink(project: string, ruleId: string) { 338 return `https://${SETTINGS.luciAnalysis.host}/p/${project}/rules/${ruleId}`; 339 }