Merge pull request #27915 from backstage/freben/stream-entities

Implement `/entities` in terms of `queryEntities`
This commit is contained in:
Fredrik Adelöw
2024-12-06 16:16:22 +01:00
committed by GitHub
7 changed files with 277 additions and 62 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Implement `/entities` in terms of `queryEntities` to not run into memory and performance problems on large catalogs
@@ -203,6 +203,7 @@ export interface QueryEntitiesInitialRequest {
term: string;
fields?: string[];
};
skipTotalItems?: boolean;
}
/**
@@ -1478,6 +1478,52 @@ describe('DefaultEntitiesCatalog', () => {
},
);
it.each(databases.eachSupportedId())(
'can skip totalItems, %p',
async databaseId => {
await createDatabase(databaseId);
await Promise.all(
Array(15)
.fill(0)
.map(() =>
addEntityToSearch({
apiVersion: 'a',
kind: 'k',
metadata: { name: v4() },
}),
),
);
const catalog = new DefaultEntitiesCatalog({
database: knex,
logger: mockServices.logger.mock(),
stitcher,
});
const request: QueryEntitiesInitialRequest = {
limit: 10,
credentials: mockCredentials.none(),
skipTotalItems: true,
};
let response = await catalog.queryEntities(request);
expect(response).toEqual({
totalItems: 0,
items: expect.objectContaining({ length: 10 }),
pageInfo: { nextCursor: expect.anything() },
});
response = await catalog.queryEntities({
...request,
cursor: response.pageInfo.nextCursor!,
});
expect(response).toEqual({
totalItems: 0,
items: expect.objectContaining({ length: 5 }),
pageInfo: { prevCursor: expect.anything() },
});
},
);
it.each(databases.eachSupportedId())(
'should paginate results accordingly in case of clashing items, %p',
async databaseId => {
@@ -14,11 +14,7 @@
* limitations under the License.
*/
import {
Entity,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { InputError, NotFoundError } from '@backstage/errors';
import { Knex } from 'knex';
import { chunk as lodashChunk, isEqual } from 'lodash';
@@ -49,6 +45,7 @@ import {
import { Stitcher } from '../stitching/types';
import {
expandLegacyCompoundRelationRefsInResponse,
isQueryEntitiesCursorRequest,
isQueryEntitiesInitialRequest,
} from './util';
@@ -280,27 +277,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
entities = entities.map(e => request.fields!(e));
}
// TODO(freben): This is added as a compatibility guarantee, until we can be
// sure that all adopters have re-stitched their entities so that the new
// targetRef field is present on them, and that they have stopped consuming
// the now-removed old field
// TODO(jhaals): Remove this in April 2022
for (const entity of entities) {
if (entity.relations) {
for (const relation of entity.relations as any) {
if (!relation.targetRef && relation.target) {
// This is the case where an old-form entity, not yet stitched with
// the updated code, was in the database
relation.targetRef = stringifyEntityRef(relation.target);
} else if (!relation.target && relation.targetRef) {
// This is the case where a new-form entity, stitched with the
// updated code, was in the database but we still want to produce
// the old data shape as well for compatibility reasons
relation.target = parseEntityRef(relation.targetRef);
}
}
}
}
expandLegacyCompoundRelationRefsInResponse(entities);
return {
entities,
@@ -354,6 +331,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
const cursor: Omit<Cursor, 'orderFieldValues'> & {
orderFieldValues?: (string | null)[];
skipTotalItems: boolean;
} = {
orderFields: [],
isPrevious: false,
@@ -364,7 +342,8 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
// request. The result is then embedded into the cursor for subsequent
// requests. Threfore this can be undefined here, but will then get
// populated further down.
const shouldComputeTotalItems = cursor.totalItems === undefined;
const shouldComputeTotalItems =
cursor.totalItems === undefined && !cursor.skipTotalItems;
const isFetchingBackwards = cursor.isPrevious;
if (cursor.orderFields.length > 1) {
@@ -555,8 +534,16 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
const rows = shouldComputeTotalItems || limit > 0 ? await dbQuery : [];
const totalItems =
cursor.totalItems ?? (rows.length ? Number(rows[0].count) : 0);
let totalItems: number;
if (cursor.totalItems !== undefined) {
totalItems = cursor.totalItems;
} else if (cursor.skipTotalItems) {
totalItems = 0;
} else if (rows.length) {
totalItems = Number(rows[0].count);
} else {
totalItems = 0;
}
if (isFetchingBackwards) {
rows.reverse();
@@ -832,15 +819,26 @@ export const cursorParser: z.ZodSchema<Cursor> = z.object({
function parseCursorFromRequest(
request?: QueryEntitiesRequest,
): Partial<Cursor> {
): Partial<Cursor> & { skipTotalItems: boolean } {
if (isQueryEntitiesInitialRequest(request)) {
const { filter, orderFields: sortFields = [], fullTextFilter } = request;
return { filter, orderFields: sortFields, fullTextFilter };
const {
filter,
orderFields: sortFields = [],
fullTextFilter,
skipTotalItems = false,
} = request;
return { filter, orderFields: sortFields, fullTextFilter, skipTotalItems };
}
if (isQueryEntitiesCursorRequest(request)) {
return request.cursor;
return {
...request.cursor,
// Doesn't matter here
skipTotalItems: false,
};
}
return {};
return {
skipTotalItems: false,
};
}
function invertOrder(order: EntityOrder['order']) {
@@ -135,9 +135,10 @@ describe('createRouter readonly disabled', () => {
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
];
entitiesCatalog.entities.mockResolvedValueOnce({
entities: [entities[0]],
pageInfo: { hasNextPage: false },
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: [entities[0]],
pageInfo: {},
totalItems: 1,
});
const response = await request(app).get('/entities');
@@ -147,17 +148,18 @@ describe('createRouter readonly disabled', () => {
});
it('parses single and multiple request parameters and passes them down', async () => {
entitiesCatalog.entities.mockResolvedValueOnce({
entities: [],
pageInfo: { hasNextPage: false },
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: [],
pageInfo: {},
totalItems: 0,
});
const response = await request(app).get(
'/entities?filter=a=1,a=2,b=3&filter=c=4',
);
expect(response.status).toEqual(200);
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entities).toHaveBeenCalledWith({
expect(entitiesCatalog.queryEntities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith({
filter: {
anyOf: [
{
@@ -169,7 +171,9 @@ describe('createRouter readonly disabled', () => {
{ key: 'c', values: ['4'] },
],
},
limit: 10000,
credentials: mockCredentials.user(),
skipTotalItems: true,
});
});
});
@@ -913,9 +917,10 @@ describe('createRouter readonly enabled', () => {
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
];
entitiesCatalog.entities.mockResolvedValueOnce({
entities: [entities[0]],
pageInfo: { hasNextPage: false },
entitiesCatalog.queryEntities.mockResolvedValueOnce({
items: [entities[0]],
pageInfo: {},
totalItems: 1,
});
const response = await request(app).get('/entities');
@@ -27,7 +27,7 @@ import { InputError, NotFoundError, serializeError } from '@backstage/errors';
import express from 'express';
import yn from 'yn';
import { z } from 'zod';
import { EntitiesCatalog } from '../catalog/types';
import { Cursor, EntitiesCatalog } from '../catalog/types';
import { CatalogProcessingOrchestrator } from '../processing/types';
import { validateEntityEnvelope } from '../processing/util';
import {
@@ -41,8 +41,10 @@ import { parseEntityFacetParams } from './request/parseEntityFacetParams';
import { parseEntityOrderParams } from './request/parseEntityOrderParams';
import { LocationService, RefreshService } from './types';
import {
createEntityArrayJsonStream,
disallowReadonlyMode,
encodeCursor,
expandLegacyCompoundRelationRefsInResponse,
locationInput,
validateRequestBody,
} from './util';
@@ -57,6 +59,7 @@ import {
} from '@backstage/backend-plugin-api';
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
import { AuthorizedValidationService } from './AuthorizedValidationService';
import { DeferredPromise, createDeferred } from '@backstage/types';
/**
* Options used by {@link createRouter}.
@@ -135,24 +138,99 @@ export async function createRouter(
if (entitiesCatalog) {
router
.get('/entities', async (req, res) => {
const { entities, pageInfo } = await entitiesCatalog.entities({
filter: parseEntityFilterParams(req.query),
fields: parseEntityTransformParams(req.query),
order: parseEntityOrderParams(req.query),
pagination: parseEntityPaginationParams(req.query),
credentials: await httpAuth.credentials(req),
});
const filter = parseEntityFilterParams(req.query);
const fields = parseEntityTransformParams(req.query);
const order = parseEntityOrderParams(req.query);
const pagination = parseEntityPaginationParams(req.query);
const credentials = await httpAuth.credentials(req);
// Add a Link header to the next page
if (pageInfo.hasNextPage) {
const url = new URL(`http://ignored${req.url}`);
url.searchParams.delete('offset');
url.searchParams.set('after', pageInfo.endCursor);
res.setHeader('link', `<${url.pathname}${url.search}>; rel="next"`);
// When pagination parameters are passed in, use the legacy slow path
// that loads all entities into memory
if (pagination) {
const { entities, pageInfo } = await entitiesCatalog.entities({
filter,
fields,
order,
pagination,
credentials,
});
// Add a Link header to the next page
if (pageInfo.hasNextPage) {
const url = new URL(`http://ignored${req.url}`);
url.searchParams.delete('offset');
url.searchParams.set('after', pageInfo.endCursor);
res.setHeader('link', `<${url.pathname}${url.search}>; rel="next"`);
}
res.json(entities);
return;
}
// TODO(freben): encode the pageInfo in the response
res.json(entities);
// For other read-the-entire-world cases, use queryEntities and stream
// out results.
// The write lock is used for back pressure, preventing slow readers
// from forcing our read loop to pile up response data in userspace
// buffers faster than the kernel buffer is emptied.
// https://nodejs.org/api/http.html#http_response_write_chunk_encoding_callback
const locks: { writeLock?: DeferredPromise } = {};
const controller = new AbortController();
const signal = controller.signal;
req.on('end', () => {
controller.abort(new Error('Client closed connection'));
locks.writeLock?.resolve();
delete locks.writeLock;
});
const responseStream = createEntityArrayJsonStream(res);
const limit = 10000;
let cursor: Cursor | undefined;
try {
do {
const result = await entitiesCatalog.queryEntities(
!cursor
? {
credentials,
fields,
limit,
filter,
orderFields: order,
skipTotalItems: true,
}
: { credentials, fields, limit, cursor },
);
if (result.items.length) {
await locks?.writeLock;
signal.throwIfAborted();
expandLegacyCompoundRelationRefsInResponse(result.items);
if (!responseStream.send(result.items)) {
// The kernel buffer is full. Create the lock but do not await it
// yet - we can better spend our time going to the next round of
// the loop and read from the database while we wait for it to
// drain.
locks.writeLock = createDeferred();
res.once('drain', () => {
locks.writeLock?.resolve();
delete locks.writeLock;
});
}
}
signal.throwIfAborted();
cursor = result.pageInfo?.nextCursor;
} while (cursor);
responseStream.complete();
} finally {
responseStream.close();
}
})
.get('/entities/by-query', async (req, res) => {
const { items, pageInfo, totalItems } =
+83 -1
View File
@@ -15,7 +15,7 @@
*/
import { InputError, NotAllowedError } from '@backstage/errors';
import { Request } from 'express';
import { Request, Response } from 'express';
import lodash from 'lodash';
import { z } from 'zod';
import {
@@ -25,6 +25,11 @@ import {
QueryEntitiesRequest,
} from '../catalog/types';
import { EntityFilter } from '@backstage/plugin-catalog-node';
import {
Entity,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
export async function requireRequestBody(req: Request): Promise<unknown> {
const contentType = req.header('content-type');
@@ -138,3 +143,80 @@ export function decodeCursor(encodedCursor: string) {
throw new InputError(`Malformed cursor: ${e}`);
}
}
// TODO(freben): This is added as a compatibility guarantee, until we can be
// sure that all adopters have re-stitched their entities so that the new
// targetRef field is present on them, and that they have stopped consuming
// the now-removed old field
// TODO(jhaals): Remove this in April 2022
export function expandLegacyCompoundRelationRefsInResponse(
entities: Entity[],
): void {
for (const entity of entities) {
if (entity.relations) {
for (const relation of entity.relations as any) {
if (!relation.targetRef && relation.target) {
// This is the case where an old-form entity, not yet stitched with
// the updated code, was in the database
relation.targetRef = stringifyEntityRef(relation.target);
} else if (!relation.target && relation.targetRef) {
// This is the case where a new-form entity, stitched with the
// updated code, was in the database but we still want to produce
// the old data shape as well for compatibility reasons
relation.target = parseEntityRef(relation.targetRef);
}
}
}
}
}
export interface EntityArrayJsonStream {
send(entities: Entity[]): boolean;
complete(): void;
close(): void;
}
// Helps stream Entity[] as a JSON response stream to avoid performance issues
export function createEntityArrayJsonStream(
res: Response,
): EntityArrayJsonStream {
// Imitate the httpRouter behavior of pretty-printing in development
const prettyPrint = process.env.NODE_ENV === 'development';
let firstSend = true;
let completed = false;
return {
send(entities) {
if (firstSend) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.status(200);
res.flushHeaders();
}
let data: string;
if (prettyPrint) {
data = JSON.stringify(entities, null, 2);
data = firstSend ? data.slice(0, -2) : `,\n${data.slice(2, -2)}`;
} else {
data = JSON.stringify(entities);
data = firstSend ? data.slice(0, -1) : `,${data.slice(1, -1)}`;
}
firstSend = false;
return res.write(data);
},
complete() {
if (firstSend) {
res.json([]);
} else {
res.end(prettyPrint ? '\n]' : ']');
}
completed = true;
},
close() {
if (!completed) {
res.end();
}
},
};
}