blob: c999c79c811db2c72f756b34ae5956c7be328828 [file]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {AuthorizationHeader, getAuthorizationHeader} from './auth';
import {getEarliestArtifactExpiration, getParentInv} from './resultdb-utils';
import {LRUMemoryCacheObject} from './lru-memory-cache-object';
import {Build} from './checks-fetcher';
import {FaultAttributeProperties} from './failure-analysis';
export enum TestStatus {
PASS = 'PASS',
FAIL = 'FAIL',
CRASH = 'CRASH',
ABORT = 'ABORT',
SKIP = 'SKIP',
}
export enum TestVariantStatus {
UNEXPECTED = 'UNEXPECTED',
UNEXPECTEDLY_SKIPPED = 'UNEXPECTEDLY_SKIPPED',
FLAKY = 'FLAKY',
EXONERATED = 'EXONERATED',
UNEXPECTED_MASK = 'UNEXPECTED_MASK',
EXPECTED = 'EXPECTED',
}
export declare interface TestVariant {
variant: any;
results: {result: TestResult}[];
exonerations: any[];
status: TestVariantStatus;
testId?: string;
variantHash?: string;
testMetadata?: {
name: any;
};
faultAttribute?: FaultAttributeProperties;
}
export declare interface TestResult {
name: string;
testId?: string;
resultId?: string;
tags: {[key: string]: string}[];
status?: TestStatus;
summaryHtml?: string;
expected?: boolean;
}
export declare interface Artifact {
name: string;
artifactId: string;
fetchUrl: string;
fetchUrlExpiration?: string;
contentType?: string;
sizeBytes: number;
contents?: any;
}
declare interface TestVariantsQuery {
invocations: any[];
predicate: {
status: TestVariantStatus;
};
pageSize: number;
}
declare interface listAllArtifactsQuery {
parent: string;
pageSize: number;
pageToken: string;
}
// TODO(gavinmak): Use IndexedDB.
const artifactCache = new LRUMemoryCacheObject(256);
const variantCache = new LRUMemoryCacheObject(512);
/**
* Client for ResultDB v1 API.
* Requests and responses to and from the RPC methods are defined in:
* https://osscs.corp.google.com/chromium/infra/infra/+/master:go/src/go.chromium.org/luci/resultdb/proto/v1/resultdb.proto
* For more help see:
* https://goto.google.com/resultdb-rpc
*/
export class ResultDbV1Client {
host: string;
changeId: string;
constructor(host: string, changeId: string) {
this.host = host;
this.changeId = changeId;
}
/**
* Calls a ResultDB v1 API method.
*
* @param method RPC service method name, e.g. "GetInvocation".
* @param request Request body.
* @param signal An AbortSignal object.
* @return Response body.
*/
private async call(
service: string,
method: string,
request: TestVariantsQuery | listAllArtifactsQuery,
signal: AbortSignal | undefined = undefined
): Promise<any> {
// Miniature implementation of pRPC protocol with JSONPB and auth.
const authHeader = await this.getAuthorizationHeader();
const headers = {
accept: 'application/json',
'content-type': 'application/json',
...authHeader,
};
const url = `https://${this.host}/prpc/${service}/${method}`;
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(request),
signal,
}).catch(e => {
if (e.name === 'AbortError') {
console.warn('ResultDB request aborted');
} else {
throw e;
}
});
// If fetch is aborted, return an empty object.
if (!response) {
return {};
}
if (!response.ok) {
throw new Error('ResultDB request failed');
}
const rawResponseText = await response.text();
const xssiPrefix = ")]}'";
if (!rawResponseText.startsWith(xssiPrefix)) {
throw new Error(
`Response body does not start with XSSI prefix: ${xssiPrefix}`
);
}
return JSON.parse(rawResponseText.substr(xssiPrefix.length));
}
queryTestVariants(
request: TestVariantsQuery,
signal = undefined
): Promise<{testVariants: TestVariant[]}> {
return this.call(
'luci.resultdb.v1.ResultDB',
'QueryTestVariants',
request,
signal
);
}
listArtifacts(
request: listAllArtifactsQuery
): Promise<{artifacts: Artifact[]; nextPageToken?: string}> {
return this.call('luci.resultdb.v1.ResultDB', 'ListArtifacts', request);
}
/**
* Returns test variants corresponding to the given build. This does not
* return expected variants.
*
* @param build Buildbucket build.
* @param limit Number of test variants to return. ResultDB coerces
* any number above 1000 to 1000.
* @param inclAdditional Whether to fetch flaky/exonerated results.
*/
async fetchTestVariants(
build: Build,
limit: number,
inclAdditional: boolean
): Promise<TestVariant[]> {
const cacheKey = build.id + (inclAdditional ? '-additional' : '');
const cachedVariants = variantCache.read(cacheKey);
if (cachedVariants) {
return cachedVariants;
}
const response = await this.queryTestVariants({
invocations: [build.infra?.resultdb?.invocation],
predicate: {
status: inclAdditional
? TestVariantStatus.UNEXPECTED_MASK
: TestVariantStatus.UNEXPECTED,
},
pageSize: limit,
});
const testVariants: TestVariant[] = response.testVariants || [];
// Cache only if build is finalized.
if (build.endTime) {
// TODO(gavinmak): Move try logic to CacheObject.
try {
variantCache.write(cacheKey, testVariants);
} catch (e) {
console.warn('Failed to write to test variant cache:', e);
}
}
return testVariants;
}
/**
* Returns all artifacts for the given artifact parent.
*
* @param parent Invocation or test result name.
*/
async listAllArtifacts(parent: string): Promise<Artifact[]> {
const artifacts: Artifact[] = [];
let pageToken = '';
while (true) {
const response = await this.listArtifacts({
parent,
// Artifact entries are small - just pointers to artifact content,
// so reduce the probability of sequential RPCs by using a large
// pageSize.
pageSize: 10000,
pageToken,
});
artifacts.push(...(response.artifacts || []));
// Exit if there are no more artifacts to fetch.
if (!response.nextPageToken) {
break;
}
pageToken = response.nextPageToken;
}
return artifacts;
}
/**
* Returns artifacts for the given test result and its parent invocation. If
* allowFetch is true, then artifacts will be fetched on cache miss. The
* returned object will contain a fromCache property that is true iff the
* results are fetched from cache.
*
* @param resultName The result name.
* @param allowFetch Whether to query ResultDB.
*/
async fetchArtifacts(
resultName: string,
allowFetch: boolean
): Promise<{
fromCache: boolean;
resArtifacts: Artifact[];
invArtifacts: Artifact[];
}> {
const cacheKey = `${this.host}-${resultName}`;
const cachedArtifacts = artifactCache.read(cacheKey);
if (cachedArtifacts) {
const {expiry, resArtifacts, invArtifacts} = cachedArtifacts;
// Check if the cached results will expire in the next minute.
if (expiry > Date.now() + 1000 * 60) {
return {
fromCache: true,
resArtifacts,
invArtifacts,
};
}
artifactCache.delete(cacheKey);
}
if (!allowFetch) {
return {
fromCache: false,
resArtifacts: [],
invArtifacts: [],
};
}
// Query ResultDB.
// TODO(gavinmak): Don't refetch invocation-level artifacts when processing
// multiple test results.
let allSucceeded = true;
const [resArtifacts, invArtifacts] = await Promise.all([
this.listAllArtifacts(resultName).catch(e => {
allSucceeded = false;
console.warn('Failed to fetch result artifacts:', e);
return [];
}),
this.listAllArtifacts(getParentInv(resultName)).catch(e => {
allSucceeded = false;
console.warn('Failed to fetch invocation-level artifacts:', e);
return [];
}),
]);
if (allSucceeded) {
// TODO(gavinmak): Move try logic to CacheObject.
try {
artifactCache.write(cacheKey, {
expiry: getEarliestArtifactExpiration([
...resArtifacts,
...invArtifacts,
]),
resArtifacts,
invArtifacts,
});
} catch (e) {
console.warn('Failed to write to artifact cache:', e);
}
}
return {
fromCache: false,
resArtifacts,
invArtifacts,
};
}
/**
* Mockable equivalent of window.buildbucket.getAuthorizationHeader.
*
* @returns authorization header to use in requests.
*/
getAuthorizationHeader(): Promise<AuthorizationHeader> {
return getAuthorizationHeader(this.changeId);
}
}