Skip to content

Commit 1899657

Browse files
[8.x] fix(slo): introduce cursor pagination in Find SLO API (elastic#203712) (elastic#205932)
# Backport This will backport the following commits from `main` to `8.x`: - [fix(slo): introduce cursor pagination in Find SLO API (elastic#203712)](elastic#203712) <!--- Backport version: 8.9.8 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Kevin Delemme","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-01-08T16:07:23Z","message":"fix(slo): introduce cursor pagination in Find SLO API (elastic#203712)","sha":"464d361cc72b1e66e3fcd1655021bc5becc894b3","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","v9.0.0","backport:prev-minor","ci:project-deploy-observability","Team:obs-ux-management","v8.18.0"],"number":203712,"url":"https://github.com/elastic/kibana/pull/203712","mergeCommit":{"message":"fix(slo): introduce cursor pagination in Find SLO API (elastic#203712)","sha":"464d361cc72b1e66e3fcd1655021bc5becc894b3"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/203712","number":203712,"mergeCommit":{"message":"fix(slo): introduce cursor pagination in Find SLO API (elastic#203712)","sha":"464d361cc72b1e66e3fcd1655021bc5becc894b3"}},{"branch":"8.x","label":"v8.18.0","labelRegex":"^v8.18.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> --------- Co-authored-by: kibanamachine <[email protected]>
1 parent bb9d120 commit 1899657

File tree

14 files changed

+327
-87
lines changed

14 files changed

+327
-87
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { merge } from 'lodash';
9+
import { findSLOParamsSchema } from './find';
10+
11+
const BASE_REQUEST = {
12+
query: {
13+
filters: 'irrelevant',
14+
kqlQuery: 'irrelevant',
15+
page: '1',
16+
perPage: '25',
17+
sortBy: 'error_budget_consumed',
18+
sortDirection: 'asc',
19+
hideStale: true,
20+
},
21+
};
22+
23+
describe('FindSLO schema validation', () => {
24+
it.each(['not_an_array', 42, [], [42, 'ok']])(
25+
'returns an error when searchAfter is not a valid JSON array (%s)',
26+
(searchAfter) => {
27+
const request = merge(BASE_REQUEST, {
28+
query: {
29+
searchAfter,
30+
},
31+
});
32+
const result = findSLOParamsSchema.decode(request);
33+
expect(result._tag === 'Left').toBe(true);
34+
}
35+
);
36+
37+
it('parses searchAfter correctly', () => {
38+
const request = merge(BASE_REQUEST, {
39+
query: {
40+
searchAfter: JSON.stringify([1, 'ok']),
41+
},
42+
});
43+
const result = findSLOParamsSchema.decode(request);
44+
expect(result._tag === 'Right').toBe(true);
45+
});
46+
});

x-pack/platform/packages/shared/kbn-slo-schema/src/rest_specs/routes/find.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
* 2.0; you may not use this file except in compliance with the Elastic License
55
* 2.0.
66
*/
7-
import * as t from 'io-ts';
87
import { toBooleanRt } from '@kbn/io-ts-utils';
8+
import { either, isRight } from 'fp-ts/lib/Either';
9+
import * as t from 'io-ts';
910
import { sloWithDataResponseSchema } from '../slo';
1011

1112
const sortDirectionSchema = t.union([t.literal('asc'), t.literal('desc')]);
@@ -19,24 +20,64 @@ const sortBySchema = t.union([
1920
t.literal('burn_rate_1d'),
2021
]);
2122

23+
const searchAfterArraySchema = t.array(t.union([t.string, t.number]));
24+
type SearchAfterArray = t.TypeOf<typeof searchAfterArraySchema>;
25+
26+
const searchAfterSchema = new t.Type<SearchAfterArray, string, unknown>(
27+
'SearchAfter',
28+
(input: unknown): input is SearchAfterArray =>
29+
Array.isArray(input) &&
30+
input.length > 0 &&
31+
input.every((item) => typeof item === 'string' || typeof item === 'number'),
32+
(input: unknown, context: t.Context) =>
33+
either.chain(t.string.validate(input, context), (value: string) => {
34+
try {
35+
const parsedValue = JSON.parse(value);
36+
const decoded = searchAfterArraySchema.decode(parsedValue);
37+
if (isRight(decoded)) {
38+
return t.success(decoded.right);
39+
}
40+
return t.failure(
41+
input,
42+
context,
43+
'Invalid searchAfter value, must be a JSON array of strings or numbers'
44+
);
45+
} catch (err) {
46+
return t.failure(
47+
input,
48+
context,
49+
'Invalid searchAfter value, must be a JSON array of strings or numbers'
50+
);
51+
}
52+
}),
53+
(input: SearchAfterArray): string => JSON.stringify(input)
54+
);
55+
2256
const findSLOParamsSchema = t.partial({
2357
query: t.partial({
2458
filters: t.string,
2559
kqlQuery: t.string,
60+
// Used for page pagination
2661
page: t.string,
2762
perPage: t.string,
2863
sortBy: sortBySchema,
2964
sortDirection: sortDirectionSchema,
3065
hideStale: toBooleanRt,
66+
// Used for cursor pagination, searchAfter is a JSON array
67+
searchAfter: searchAfterSchema,
68+
size: t.string,
3169
}),
3270
});
3371

34-
const findSLOResponseSchema = t.type({
35-
page: t.number,
36-
perPage: t.number,
37-
total: t.number,
38-
results: t.array(sloWithDataResponseSchema),
39-
});
72+
const findSLOResponseSchema = t.intersection([
73+
t.type({
74+
page: t.number,
75+
perPage: t.number,
76+
total: t.number,
77+
results: t.array(sloWithDataResponseSchema),
78+
}),
79+
t.partial({ searchAfter: searchAfterArraySchema, size: t.number }),
80+
]);
4081

4182
type FindSLOParams = t.TypeOf<typeof findSLOParamsSchema.props.query>;
4283
type FindSLOResponse = t.OutputOf<typeof findSLOResponseSchema>;

x-pack/solutions/observability/plugins/slo/server/routes/slo/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import { ManageSLO } from '../../services/manage_slo';
5757
import { ResetSLO } from '../../services/reset_slo';
5858
import { SloDefinitionClient } from '../../services/slo_definition_client';
5959
import { getSloSettings, storeSloSettings } from '../../services/slo_settings';
60-
import { DefaultSummarySearchClient } from '../../services/summary_search_client';
60+
import { DefaultSummarySearchClient } from '../../services/summary_search_client/summary_search_client';
6161
import { DefaultSummaryTransformGenerator } from '../../services/summary_transform_generator/summary_transform_generator';
6262
import { createTransformGenerators } from '../../services/transform_generators';
6363
import { createSloServerRoute } from '../create_slo_server_route';

x-pack/solutions/observability/plugins/slo/server/services/find_slo.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { FindSLO } from './find_slo';
1212
import { createSLO } from './fixtures/slo';
1313
import { createSLORepositoryMock, createSummarySearchClientMock } from './mocks';
1414
import { SLORepository } from './slo_repository';
15-
import { SummaryResult, SummarySearchClient } from './summary_search_client';
15+
import type { SummaryResult, SummarySearchClient } from './summary_search_client/types';
1616

1717
describe('FindSLO', () => {
1818
let mockRepository: jest.Mocked<SLORepository>;
@@ -151,16 +151,27 @@ describe('FindSLO', () => {
151151
});
152152

153153
describe('validation', () => {
154-
it("throws an error when 'perPage > 5000'", async () => {
154+
beforeEach(() => {
155155
const slo = createSLO();
156156
mockSummarySearchClient.search.mockResolvedValueOnce(summarySearchResult(slo));
157157
mockRepository.findAllByIds.mockResolvedValueOnce([slo]);
158+
});
158159

160+
it("throws an error when 'perPage' > 5000", async () => {
159161
await expect(findSLO.execute({ perPage: '5000' })).resolves.not.toThrow();
160162
await expect(findSLO.execute({ perPage: '5001' })).rejects.toThrowError(
161163
'perPage limit set to 5000'
162164
);
163165
});
166+
167+
describe('Cursor Pagination', () => {
168+
it("throws an error when 'size' > 5000", async () => {
169+
await expect(findSLO.execute({ size: '5000' })).resolves.not.toThrow();
170+
await expect(findSLO.execute({ size: '5001' })).rejects.toThrowError(
171+
'size limit set to 5000'
172+
);
173+
});
174+
});
164175
});
165176
});
166177

x-pack/solutions/observability/plugins/slo/server/services/find_slo.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,22 @@
55
* 2.0.
66
*/
77

8-
import { FindSLOParams, FindSLOResponse, findSLOResponseSchema, Pagination } from '@kbn/slo-schema';
8+
import { FindSLOParams, FindSLOResponse, findSLOResponseSchema } from '@kbn/slo-schema';
99
import { keyBy } from 'lodash';
1010
import { SLODefinition } from '../domain/models';
1111
import { IllegalArgumentError } from '../errors';
1212
import { SLORepository } from './slo_repository';
13-
import { Sort, SummaryResult, SummarySearchClient } from './summary_search_client';
13+
import type {
14+
Pagination,
15+
Sort,
16+
SummaryResult,
17+
SummarySearchClient,
18+
} from './summary_search_client/types';
1419

1520
const DEFAULT_PAGE = 1;
1621
const DEFAULT_PER_PAGE = 25;
17-
const MAX_PER_PAGE = 5000;
22+
const DEFAULT_SIZE = 100;
23+
const MAX_PER_PAGE_OR_SIZE = 5000;
1824

1925
export class FindSLO {
2026
constructor(
@@ -38,8 +44,10 @@ export class FindSLO {
3844
);
3945

4046
return findSLOResponseSchema.encode({
41-
page: summaryResults.page,
42-
perPage: summaryResults.perPage,
47+
page: 'page' in summaryResults ? summaryResults.page : DEFAULT_PAGE,
48+
perPage: 'perPage' in summaryResults ? summaryResults.perPage : DEFAULT_PER_PAGE,
49+
size: 'size' in summaryResults ? summaryResults.size : undefined,
50+
searchAfter: 'searchAfter' in summaryResults ? summaryResults.searchAfter : undefined,
4351
total: summaryResults.total,
4452
results: mergeSloWithSummary(localSloDefinitions, summaryResults.results),
4553
});
@@ -78,16 +86,29 @@ function mergeSloWithSummary(
7886
}
7987

8088
function toPagination(params: FindSLOParams): Pagination {
89+
const isCursorBased = !!params.searchAfter || !!params.size;
90+
91+
if (isCursorBased) {
92+
const size = Number(params.size);
93+
if (!isNaN(size) && size > MAX_PER_PAGE_OR_SIZE) {
94+
throw new IllegalArgumentError('size limit set to 5000');
95+
}
96+
97+
return {
98+
searchAfter: params.searchAfter,
99+
size: !isNaN(size) && size > 0 ? size : DEFAULT_SIZE,
100+
};
101+
}
102+
81103
const page = Number(params.page);
82104
const perPage = Number(params.perPage);
83-
84-
if (!isNaN(perPage) && perPage > MAX_PER_PAGE) {
85-
throw new IllegalArgumentError(`perPage limit set to ${MAX_PER_PAGE}`);
105+
if (!isNaN(perPage) && perPage > MAX_PER_PAGE_OR_SIZE) {
106+
throw new IllegalArgumentError('perPage limit set to 5000');
86107
}
87108

88109
return {
89110
page: !isNaN(page) && page >= 1 ? page : DEFAULT_PAGE,
90-
perPage: !isNaN(perPage) && perPage >= 0 ? perPage : DEFAULT_PER_PAGE,
111+
perPage: !isNaN(perPage) && perPage > 0 ? perPage : DEFAULT_PER_PAGE,
91112
};
92113
}
93114

x-pack/solutions/observability/plugins/slo/server/services/fixtures/summary_search_document.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const aSummaryDocument = (
2525

2626
export const aHitFromSummaryIndex = (_source: any) => {
2727
return {
28-
_index: '.slo-observability.summary-v2',
28+
_index: '.slo-observability.summary-v3.3',
2929
_id: uuidv4(),
3030
_score: 1,
3131
_source,
@@ -34,7 +34,7 @@ export const aHitFromSummaryIndex = (_source: any) => {
3434

3535
export const aHitFromTempSummaryIndex = (_source: any) => {
3636
return {
37-
_index: '.slo-observability.summary-v2.temp',
37+
_index: '.slo-observability.summary-v3.3.temp',
3838
_id: uuidv4(),
3939
_score: 1,
4040
_source,

x-pack/solutions/observability/plugins/slo/server/services/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export * from './summary_client';
2222
export * from './get_slo_groupings';
2323
export * from './find_slo_groups';
2424
export * from './get_slo_health';
25+
export * from './summary_search_client/summary_search_client';

x-pack/solutions/observability/plugins/slo/server/services/mocks/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { ResourceInstaller } from '../resource_installer';
99
import { BurnRatesClient } from '../burn_rates_client';
1010
import { SLORepository } from '../slo_repository';
1111
import { SummaryClient } from '../summary_client';
12-
import { SummarySearchClient } from '../summary_search_client';
12+
import { SummarySearchClient } from '../summary_search_client/types';
1313
import { TransformManager } from '../transform_manager';
1414

1515
const createResourceInstallerMock = (): jest.Mocked<ResourceInstaller> => {

x-pack/solutions/observability/plugins/slo/server/services/summary_search_client.test.ts renamed to x-pack/solutions/observability/plugins/slo/server/services/summary_search_client/summary_search_client.test.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
99
import { loggerMock } from '@kbn/logging-mocks';
1010
import { Pagination } from '@kbn/slo-schema/src/models/pagination';
11-
import { createSLO } from './fixtures/slo';
11+
import { createSLO } from '../fixtures/slo';
1212
import {
1313
aHitFromSummaryIndex,
1414
aHitFromTempSummaryIndex,
1515
aSummaryDocument,
16-
} from './fixtures/summary_search_document';
17-
import { DefaultSummarySearchClient, Sort, SummarySearchClient } from './summary_search_client';
16+
} from '../fixtures/summary_search_document';
17+
import { DefaultSummarySearchClient } from './summary_search_client';
18+
import type { Sort, SummarySearchClient } from './types';
1819

1920
const defaultSort: Sort = {
2021
field: 'sli_value',
@@ -169,6 +170,12 @@ describe('Summary Search Client', () => {
169170
sliValue: {
170171
order: 'asc',
171172
},
173+
'slo.id': {
174+
order: 'asc',
175+
},
176+
'slo.instanceId': {
177+
order: 'asc',
178+
},
172179
},
173180
track_total_hits: true,
174181
},
@@ -202,6 +209,12 @@ describe('Summary Search Client', () => {
202209
sliValue: {
203210
order: 'asc',
204211
},
212+
'slo.id': {
213+
order: 'asc',
214+
},
215+
'slo.instanceId': {
216+
order: 'asc',
217+
},
205218
},
206219
track_total_hits: true,
207220
},
@@ -229,7 +242,16 @@ describe('Summary Search Client', () => {
229242
},
230243
},
231244
size: 40,
232-
sort: { isTempDoc: { order: 'asc' }, sliValue: { order: 'asc' } },
245+
sort: {
246+
isTempDoc: { order: 'asc' },
247+
sliValue: { order: 'asc' },
248+
'slo.id': {
249+
order: 'asc',
250+
},
251+
'slo.instanceId': {
252+
order: 'asc',
253+
},
254+
},
233255
track_total_hits: true,
234256
},
235257
]);

0 commit comments

Comments
 (0)