fix(slo): introduce cursor pagination in Find SLO API (#203712)

This commit is contained in:
Kevin Delemme 2025-01-08 11:07:23 -05:00 committed by GitHub
parent a8e1bf46a3
commit 464d361cc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 323 additions and 84 deletions

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { merge } from 'lodash';
import { findSLOParamsSchema } from './find';
const BASE_REQUEST = {
query: {
filters: 'irrelevant',
kqlQuery: 'irrelevant',
page: '1',
perPage: '25',
sortBy: 'error_budget_consumed',
sortDirection: 'asc',
hideStale: true,
},
};
describe('FindSLO schema validation', () => {
it.each(['not_an_array', 42, [], [42, 'ok']])(
'returns an error when searchAfter is not a valid JSON array (%s)',
(searchAfter) => {
const request = merge(BASE_REQUEST, {
query: {
searchAfter,
},
});
const result = findSLOParamsSchema.decode(request);
expect(result._tag === 'Left').toBe(true);
}
);
it('parses searchAfter correctly', () => {
const request = merge(BASE_REQUEST, {
query: {
searchAfter: JSON.stringify([1, 'ok']),
},
});
const result = findSLOParamsSchema.decode(request);
expect(result._tag === 'Right').toBe(true);
});
});

View file

@ -4,8 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { toBooleanRt } from '@kbn/io-ts-utils';
import { either, isRight } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import { sloWithDataResponseSchema } from '../slo';
const sortDirectionSchema = t.union([t.literal('asc'), t.literal('desc')]);
@ -19,24 +20,64 @@ const sortBySchema = t.union([
t.literal('burn_rate_1d'),
]);
const searchAfterArraySchema = t.array(t.union([t.string, t.number]));
type SearchAfterArray = t.TypeOf<typeof searchAfterArraySchema>;
const searchAfterSchema = new t.Type<SearchAfterArray, string, unknown>(
'SearchAfter',
(input: unknown): input is SearchAfterArray =>
Array.isArray(input) &&
input.length > 0 &&
input.every((item) => typeof item === 'string' || typeof item === 'number'),
(input: unknown, context: t.Context) =>
either.chain(t.string.validate(input, context), (value: string) => {
try {
const parsedValue = JSON.parse(value);
const decoded = searchAfterArraySchema.decode(parsedValue);
if (isRight(decoded)) {
return t.success(decoded.right);
}
return t.failure(
input,
context,
'Invalid searchAfter value, must be a JSON array of strings or numbers'
);
} catch (err) {
return t.failure(
input,
context,
'Invalid searchAfter value, must be a JSON array of strings or numbers'
);
}
}),
(input: SearchAfterArray): string => JSON.stringify(input)
);
const findSLOParamsSchema = t.partial({
query: t.partial({
filters: t.string,
kqlQuery: t.string,
// Used for page pagination
page: t.string,
perPage: t.string,
sortBy: sortBySchema,
sortDirection: sortDirectionSchema,
hideStale: toBooleanRt,
// Used for cursor pagination, searchAfter is a JSON array
searchAfter: searchAfterSchema,
size: t.string,
}),
});
const findSLOResponseSchema = t.type({
page: t.number,
perPage: t.number,
total: t.number,
results: t.array(sloWithDataResponseSchema),
});
const findSLOResponseSchema = t.intersection([
t.type({
page: t.number,
perPage: t.number,
total: t.number,
results: t.array(sloWithDataResponseSchema),
}),
t.partial({ searchAfter: searchAfterArraySchema, size: t.number }),
]);
type FindSLOParams = t.TypeOf<typeof findSLOParamsSchema.props.query>;
type FindSLOResponse = t.OutputOf<typeof findSLOResponseSchema>;

View file

@ -57,7 +57,7 @@ import { ManageSLO } from '../../services/manage_slo';
import { ResetSLO } from '../../services/reset_slo';
import { SloDefinitionClient } from '../../services/slo_definition_client';
import { getSloSettings, storeSloSettings } from '../../services/slo_settings';
import { DefaultSummarySearchClient } from '../../services/summary_search_client';
import { DefaultSummarySearchClient } from '../../services/summary_search_client/summary_search_client';
import { DefaultSummaryTransformGenerator } from '../../services/summary_transform_generator/summary_transform_generator';
import { createTransformGenerators } from '../../services/transform_generators';
import { createSloServerRoute } from '../create_slo_server_route';

View file

@ -12,7 +12,7 @@ import { FindSLO } from './find_slo';
import { createSLO } from './fixtures/slo';
import { createSLORepositoryMock, createSummarySearchClientMock } from './mocks';
import { SLORepository } from './slo_repository';
import { SummaryResult, SummarySearchClient } from './summary_search_client';
import type { SummaryResult, SummarySearchClient } from './summary_search_client/types';
describe('FindSLO', () => {
let mockRepository: jest.Mocked<SLORepository>;
@ -151,16 +151,27 @@ describe('FindSLO', () => {
});
describe('validation', () => {
it("throws an error when 'perPage > 5000'", async () => {
beforeEach(() => {
const slo = createSLO();
mockSummarySearchClient.search.mockResolvedValueOnce(summarySearchResult(slo));
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
});
it("throws an error when 'perPage' > 5000", async () => {
await expect(findSLO.execute({ perPage: '5000' })).resolves.not.toThrow();
await expect(findSLO.execute({ perPage: '5001' })).rejects.toThrowError(
'perPage limit set to 5000'
);
});
describe('Cursor Pagination', () => {
it("throws an error when 'size' > 5000", async () => {
await expect(findSLO.execute({ size: '5000' })).resolves.not.toThrow();
await expect(findSLO.execute({ size: '5001' })).rejects.toThrowError(
'size limit set to 5000'
);
});
});
});
});

View file

@ -5,16 +5,22 @@
* 2.0.
*/
import { FindSLOParams, FindSLOResponse, findSLOResponseSchema, Pagination } from '@kbn/slo-schema';
import { FindSLOParams, FindSLOResponse, findSLOResponseSchema } from '@kbn/slo-schema';
import { keyBy } from 'lodash';
import { SLODefinition } from '../domain/models';
import { IllegalArgumentError } from '../errors';
import { SLORepository } from './slo_repository';
import { Sort, SummaryResult, SummarySearchClient } from './summary_search_client';
import type {
Pagination,
Sort,
SummaryResult,
SummarySearchClient,
} from './summary_search_client/types';
const DEFAULT_PAGE = 1;
const DEFAULT_PER_PAGE = 25;
const MAX_PER_PAGE = 5000;
const DEFAULT_SIZE = 100;
const MAX_PER_PAGE_OR_SIZE = 5000;
export class FindSLO {
constructor(
@ -38,8 +44,10 @@ export class FindSLO {
);
return findSLOResponseSchema.encode({
page: summaryResults.page,
perPage: summaryResults.perPage,
page: 'page' in summaryResults ? summaryResults.page : DEFAULT_PAGE,
perPage: 'perPage' in summaryResults ? summaryResults.perPage : DEFAULT_PER_PAGE,
size: 'size' in summaryResults ? summaryResults.size : undefined,
searchAfter: 'searchAfter' in summaryResults ? summaryResults.searchAfter : undefined,
total: summaryResults.total,
results: mergeSloWithSummary(localSloDefinitions, summaryResults.results),
});
@ -78,16 +86,29 @@ function mergeSloWithSummary(
}
function toPagination(params: FindSLOParams): Pagination {
const isCursorBased = !!params.searchAfter || !!params.size;
if (isCursorBased) {
const size = Number(params.size);
if (!isNaN(size) && size > MAX_PER_PAGE_OR_SIZE) {
throw new IllegalArgumentError('size limit set to 5000');
}
return {
searchAfter: params.searchAfter,
size: !isNaN(size) && size > 0 ? size : DEFAULT_SIZE,
};
}
const page = Number(params.page);
const perPage = Number(params.perPage);
if (!isNaN(perPage) && perPage > MAX_PER_PAGE) {
throw new IllegalArgumentError(`perPage limit set to ${MAX_PER_PAGE}`);
if (!isNaN(perPage) && perPage > MAX_PER_PAGE_OR_SIZE) {
throw new IllegalArgumentError('perPage limit set to 5000');
}
return {
page: !isNaN(page) && page >= 1 ? page : DEFAULT_PAGE,
perPage: !isNaN(perPage) && perPage >= 0 ? perPage : DEFAULT_PER_PAGE,
perPage: !isNaN(perPage) && perPage > 0 ? perPage : DEFAULT_PER_PAGE,
};
}

View file

@ -25,7 +25,7 @@ export const aSummaryDocument = (
export const aHitFromSummaryIndex = (_source: any) => {
return {
_index: '.slo-observability.summary-v2',
_index: '.slo-observability.summary-v3.3',
_id: uuidv4(),
_score: 1,
_source,
@ -34,7 +34,7 @@ export const aHitFromSummaryIndex = (_source: any) => {
export const aHitFromTempSummaryIndex = (_source: any) => {
return {
_index: '.slo-observability.summary-v2.temp',
_index: '.slo-observability.summary-v3.3.temp',
_id: uuidv4(),
_score: 1,
_source,

View file

@ -22,3 +22,4 @@ export * from './summary_client';
export * from './get_slo_groupings';
export * from './find_slo_groups';
export * from './get_slo_health';
export * from './summary_search_client/summary_search_client';

View file

@ -9,7 +9,7 @@ import { ResourceInstaller } from '../resource_installer';
import { BurnRatesClient } from '../burn_rates_client';
import { SLORepository } from '../slo_repository';
import { SummaryClient } from '../summary_client';
import { SummarySearchClient } from '../summary_search_client';
import { SummarySearchClient } from '../summary_search_client/types';
import { TransformManager } from '../transform_manager';
const createResourceInstallerMock = (): jest.Mocked<ResourceInstaller> => {

View file

@ -8,13 +8,14 @@
import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { Pagination } from '@kbn/slo-schema/src/models/pagination';
import { createSLO } from './fixtures/slo';
import { createSLO } from '../fixtures/slo';
import {
aHitFromSummaryIndex,
aHitFromTempSummaryIndex,
aSummaryDocument,
} from './fixtures/summary_search_document';
import { DefaultSummarySearchClient, Sort, SummarySearchClient } from './summary_search_client';
} from '../fixtures/summary_search_document';
import { DefaultSummarySearchClient } from './summary_search_client';
import type { Sort, SummarySearchClient } from './types';
const defaultSort: Sort = {
field: 'sli_value',
@ -169,6 +170,12 @@ describe('Summary Search Client', () => {
sliValue: {
order: 'asc',
},
'slo.id': {
order: 'asc',
},
'slo.instanceId': {
order: 'asc',
},
},
track_total_hits: true,
},
@ -202,6 +209,12 @@ describe('Summary Search Client', () => {
sliValue: {
order: 'asc',
},
'slo.id': {
order: 'asc',
},
'slo.instanceId': {
order: 'asc',
},
},
track_total_hits: true,
},
@ -229,7 +242,16 @@ describe('Summary Search Client', () => {
},
},
size: 40,
sort: { isTempDoc: { order: 'asc' }, sliValue: { order: 'asc' } },
sort: {
isTempDoc: { order: 'asc' },
sliValue: { order: 'asc' },
'slo.id': {
order: 'asc',
},
'slo.instanceId': {
order: 'asc',
},
},
track_total_hits: true,
},
]);

View file

@ -5,58 +5,30 @@
* 2.0.
*/
import { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { isCCSRemoteIndexName } from '@kbn/es-query';
import { ALL_VALUE, Paginated, Pagination } from '@kbn/slo-schema';
import { ALL_VALUE } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import { partition } from 'lodash';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../common/constants';
import { Groupings, SLODefinition, SLOId, StoredSLOSettings, Summary } from '../domain/models';
import { toHighPrecision } from '../utils/number';
import { createEsParams, typedSearch } from '../utils/queries';
import { getListOfSummaryIndices, getSloSettings } from './slo_settings';
import { EsSummaryDocument } from './summary_transform_generator/helpers/create_temp_summary';
import { getElasticsearchQueryOrThrow, parseStringFilters } from './transform_generators';
import { fromRemoteSummaryDocumentToSloDefinition } from './unsafe_federated/remote_summary_doc_to_slo';
import { getFlattenedGroupings } from './utils';
export interface SummaryResult {
sloId: SLOId;
instanceId: string;
summary: Summary;
groupings: Groupings;
remote?: {
kibanaUrl: string;
remoteName: string;
slo: SLODefinition;
};
}
type SortField =
| 'error_budget_consumed'
| 'error_budget_remaining'
| 'sli_value'
| 'status'
| 'burn_rate_5m'
| 'burn_rate_1h'
| 'burn_rate_1d';
export interface Sort {
field: SortField;
direction: 'asc' | 'desc';
}
export interface SummarySearchClient {
search(
kqlQuery: string,
filters: string,
sort: Sort,
pagination: Pagination,
hideStale?: boolean
): Promise<Paginated<SummaryResult>>;
}
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../../common/constants';
import { StoredSLOSettings } from '../../domain/models';
import { toHighPrecision } from '../../utils/number';
import { createEsParams, typedSearch } from '../../utils/queries';
import { getListOfSummaryIndices, getSloSettings } from '../slo_settings';
import { EsSummaryDocument } from '../summary_transform_generator/helpers/create_temp_summary';
import { getElasticsearchQueryOrThrow, parseStringFilters } from '../transform_generators';
import { fromRemoteSummaryDocumentToSloDefinition } from '../unsafe_federated/remote_summary_doc_to_slo';
import { getFlattenedGroupings } from '../utils';
import type {
Paginated,
Pagination,
Sort,
SortField,
SummaryResult,
SummarySearchClient,
} from './types';
import { isCursorPagination } from './types';
export class DefaultSummarySearchClient implements SummarySearchClient {
constructor(
@ -76,6 +48,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
const parsedFilters = parseStringFilters(filters, this.logger);
const settings = await getSloSettings(this.soClient);
const { indices } = await getListOfSummaryIndices(this.esClient, settings);
const esParams = createEsParams({
index: indices,
track_total_hits: true,
@ -98,9 +71,14 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
[toDocumentSortField(sort.field)]: {
order: sort.direction,
},
'slo.id': {
order: 'asc',
},
'slo.instanceId': {
order: 'asc',
},
},
from: (pagination.page - 1) * pagination.perPage,
size: pagination.perPage * 2, // twice as much as we return, in case they are all duplicate temp/non-temp summary
...toPaginationQuery(pagination),
});
try {
@ -109,9 +87,9 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
esParams
);
const total = (summarySearch.hits.total as SearchTotalHits).value ?? 0;
const total = summarySearch.hits.total.value ?? 0;
if (total === 0) {
return { total: 0, perPage: pagination.perPage, page: pagination.page, results: [] };
return { total: 0, ...pagination, results: [] };
}
const [tempSummaryDocuments, summaryDocuments] = partition(
@ -129,12 +107,16 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
const finalResults = summaryDocuments
.concat(tempSummaryDocumentsDeduped)
.slice(0, pagination.perPage);
.slice(0, isCursorPagination(pagination) ? pagination.size : pagination.perPage);
const finalTotal = total - (tempSummaryDocuments.length - tempSummaryDocumentsDeduped.length);
const paginationResults = isCursorPagination(pagination)
? { searchAfter: finalResults[finalResults.length - 1].sort, size: pagination.size }
: pagination;
return {
...pagination,
...paginationResults,
total: finalTotal,
results: finalResults.map((doc) => {
const summaryDoc = doc._source;
@ -179,7 +161,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
};
} catch (err) {
this.logger.error(`Error while searching SLO summary documents. ${err}`);
return { total: 0, perPage: pagination.perPage, page: pagination.page, results: [] };
return { total: 0, ...pagination, results: [] };
}
}
@ -251,3 +233,19 @@ function toDocumentSortField(field: SortField) {
assertNever(field);
}
}
function toPaginationQuery(
pagination: Pagination
): { size: number; search_after?: Array<string | number> } | { size: number; from: number } {
if (isCursorPagination(pagination)) {
return {
size: pagination.size * 2, // Potential duplicates between temp and non-temp summaries
search_after: pagination.searchAfter,
};
}
return {
size: pagination.perPage * 2, // Potential duplicates between temp and non-temp summaries
from: (pagination.page - 1) * pagination.perPage,
};
}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Groupings, SLODefinition, SLOId, Summary } from '../../domain/models';
interface SummaryResult {
sloId: SLOId;
instanceId: string;
summary: Summary;
groupings: Groupings;
remote?: {
kibanaUrl: string;
remoteName: string;
slo: SLODefinition;
};
}
type SortField =
| 'error_budget_consumed'
| 'error_budget_remaining'
| 'sli_value'
| 'status'
| 'burn_rate_5m'
| 'burn_rate_1h'
| 'burn_rate_1d';
interface Sort {
field: SortField;
direction: 'asc' | 'desc';
}
type Pagination = CursorPagination | OffsetPagination;
interface CursorPagination {
searchAfter?: Array<string | number>;
size: number;
}
function isCursorPagination(pagination: Pagination): pagination is CursorPagination {
return (pagination as CursorPagination).size !== undefined;
}
interface OffsetPagination {
page: number;
perPage: number;
}
type Paginated<T> = CursorPaginated<T> | OffsetPaginated<T>;
interface CursorPaginated<T> {
total: number;
searchAfter?: Array<string | number>;
size: number;
results: T[];
}
interface OffsetPaginated<T> {
total: number;
page: number;
perPage: number;
results: T[];
}
interface SummarySearchClient {
search(
kqlQuery: string,
filters: string,
sort: Sort,
pagination: Pagination,
hideStale?: boolean
): Promise<Paginated<SummaryResult>>;
}
export type {
SummaryResult,
SortField,
Sort,
Pagination,
CursorPagination,
OffsetPagination as PagePagination,
Paginated,
CursorPaginated,
OffsetPaginated as PagePaginated,
SummarySearchClient,
};
export { isCursorPagination };

View file

@ -40,9 +40,8 @@ export function parseStringFilters(filters: string, logger: Logger) {
return JSON.parse(filters);
} catch (e) {
logger.info(`Failed to parse filters: ${e}`);
return {};
}
return {};
}
export function parseIndex(index: string): string | string[] {

View file

@ -65,24 +65,34 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
await retry.tryForTime(180 * 1000, async () => {
let response = await supertestWithoutAuth
.get(`/api/observability/slos`)
.query({ page: 1, perPage: 333 })
.set(adminRoleAuthc.apiKeyHeader)
.set(internalHeaders)
.send();
expect(response.body.results).length(2);
expect(response.body.page).eql(1);
expect(response.body.perPage).eql(333);
expect(response.body.total).eql(2);
response = await supertestWithoutAuth
.get(`/api/observability/slos?kqlQuery=slo.name%3Airrelevant`)
.get(`/api/observability/slos`)
.query({ size: 222, kqlQuery: 'slo.name:irrelevant' })
.set(adminRoleAuthc.apiKeyHeader)
.set(internalHeaders)
.send()
.expect(200);
expect(response.body.page).eql(1); // always return page with default value
expect(response.body.perPage).eql(25); // always return perPage with default value
expect(response.body.size).eql(222);
expect(response.body.searchAfter).ok();
expect(response.body.results).length(1);
expect(response.body.results[0].id).eql(sloId2);
response = await supertestWithoutAuth
.get(`/api/observability/slos?kqlQuery=slo.name%3Aintegration`)
.get(`/api/observability/slos`)
.query({ kqlQuery: 'slo.name:integration' })
.set(adminRoleAuthc.apiKeyHeader)
.set(internalHeaders)
.send()