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  }