From cf195de72cd00b876f7b03adb8ba126e0d2091be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 20 Apr 2026 14:02:02 +0200 Subject: [PATCH 1/7] catalog-backend: fix facets endpoint performance regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the EXISTS-based filters through final_entities (one row per entity) instead of correlating against the search table directly. This avoids the pathological case where correlated subqueries scan the much larger search table for every row in the outer facets query. Signed-off-by: Fredrik Adelöw Made-with: Cursor --- .changeset/fix-facets-perf-regression.md | 5 +++++ .../src/service/DefaultEntitiesCatalog.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-facets-perf-regression.md diff --git a/.changeset/fix-facets-perf-regression.md b/.changeset/fix-facets-perf-regression.md new file mode 100644 index 0000000000..71f293271a --- /dev/null +++ b/.changeset/fix-facets-perf-regression.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fixed a performance regression in the `/entity-facets` endpoint when filters or permission conditions are applied, by routing the EXISTS-based filter through `final_entities` instead of correlating against the much larger `search` table. diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 7fda0ac0f1..830120e486 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -690,13 +690,24 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { .groupBy(['search.key', 'search.original_value']); if (request.filter || request.query) { + // Build a subquery that finds matching entity IDs via + // final_entities, so that the EXISTS-based filters correlate + // against one-row-per-entity rather than the much larger search + // table. This keeps the facets aggregation fast even with many + // filter clauses or permission conditions. + const entityIdSubquery = this.database('final_entities') + .select('final_entities.entity_id') + .whereNotNull('final_entities.final_entity'); + applyEntityFilterToQuery({ filter: request.filter, query: request.query, - targetQuery: query, - onEntityIdField: 'search.entity_id', + targetQuery: entityIdSubquery, + onEntityIdField: 'final_entities.entity_id', knex: this.database, }); + + query.whereIn('search.entity_id', entityIdSubquery); } const rows = await query; From 673587dd4fab3c9f91b218f3771f30e13ff15953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 20 Apr 2026 14:03:19 +0200 Subject: [PATCH 2/7] Add patch release file for PR #34001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw Made-with: Cursor --- .patches/pr-34001.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .patches/pr-34001.txt diff --git a/.patches/pr-34001.txt b/.patches/pr-34001.txt new file mode 100644 index 0000000000..760a059e5f --- /dev/null +++ b/.patches/pr-34001.txt @@ -0,0 +1 @@ +Fix facets endpoint performance regression when filters or permissions are applied From 9ae08d07e2b88253a42822cb65427f125c566486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 20 Apr 2026 14:21:57 +0200 Subject: [PATCH 3/7] Add thorough facets tests for predicate queries and compound filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds standalone test coverage for the facets method in DefaultEntitiesCatalog to ensure it works correctly with: - Predicate query filtering (simple key match) - Predicate query filtering using $in operator - Compound allOf filters - Compound anyOf filters - Both filter and query combined Signed-off-by: Fredrik Adelöw Made-with: Cursor --- .../service/DefaultEntitiesCatalog.test.ts | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 3a184676df..ad931dca61 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -2437,6 +2437,246 @@ describe('DefaultEntitiesCatalog', () => { }); }, ); + + it.each(databases.eachSupportedId())( + 'filters with a predicate query, %p', + async databaseId => { + await createDatabase(databaseId); + + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'two' }, + spec: { type: 'library' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'API', + metadata: { name: 'three' }, + spec: { type: 'openapi' }, + }); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + await expect( + catalog.facets({ + facets: ['spec.type'], + query: { kind: 'component' }, + credentials: mockCredentials.none(), + }), + ).resolves.toEqual({ + facets: { + 'spec.type': expect.arrayContaining([ + { value: 'service', count: 1 }, + { value: 'library', count: 1 }, + ]), + }, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'filters with a predicate query using $in, %p', + async databaseId => { + await createDatabase(databaseId); + + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'API', + metadata: { name: 'two' }, + spec: { type: 'openapi' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'System', + metadata: { name: 'three' }, + spec: {}, + }); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + await expect( + catalog.facets({ + facets: ['kind'], + query: { kind: { $in: ['component', 'api'] } }, + credentials: mockCredentials.none(), + }), + ).resolves.toEqual({ + facets: { + kind: expect.arrayContaining([ + { value: 'Component', count: 1 }, + { value: 'API', count: 1 }, + ]), + }, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'filters with compound allOf filter, %p', + async databaseId => { + await createDatabase(databaseId); + + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'two' }, + spec: { type: 'library' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'API', + metadata: { name: 'three' }, + spec: { type: 'openapi' }, + }); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + await expect( + catalog.facets({ + facets: ['metadata.name'], + filter: { + allOf: [ + { key: 'kind', values: ['component'] }, + { key: 'spec.type', values: ['service'] }, + ], + }, + credentials: mockCredentials.none(), + }), + ).resolves.toEqual({ + facets: { + 'metadata.name': [{ value: 'one', count: 1 }], + }, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'filters with compound anyOf filter, %p', + async databaseId => { + await createDatabase(databaseId); + + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'API', + metadata: { name: 'two' }, + spec: { type: 'openapi' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'System', + metadata: { name: 'three' }, + spec: {}, + }); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + await expect( + catalog.facets({ + facets: ['metadata.name'], + filter: { + anyOf: [ + { key: 'kind', values: ['component'] }, + { key: 'kind', values: ['api'] }, + ], + }, + credentials: mockCredentials.none(), + }), + ).resolves.toEqual({ + facets: { + 'metadata.name': expect.arrayContaining([ + { value: 'one', count: 1 }, + { value: 'two', count: 1 }, + ]), + }, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'filters with both filter and query combined, %p', + async databaseId => { + await createDatabase(databaseId); + + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'two' }, + spec: { type: 'library' }, + }); + await addEntityToSearch({ + apiVersion: 'a', + kind: 'API', + metadata: { name: 'three' }, + spec: { type: 'openapi' }, + }); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + await expect( + catalog.facets({ + facets: ['spec.type'], + filter: { key: 'kind', values: ['component'] }, + query: { 'metadata.name': 'one' }, + credentials: mockCredentials.none(), + }), + ).resolves.toEqual({ + facets: { + 'spec.type': [{ value: 'service', count: 1 }], + }, + }); + }, + ); }); }); From d7c6ad8e1400be7619b2f8b848b324bad9e033ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 20 Apr 2026 14:43:22 +0200 Subject: [PATCH 4/7] Remove unnecessary whereNotNull guard from facets subquery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whereNotNull('final_entities.final_entity') was copied from other code paths that select the final_entity column, but the facets subquery only needs entity_id. Removing it preserves exact v1.49 semantics (search rows only exist for stitched entities anyway, due to the FK cascade from search -> final_entities) and avoids an inconsistency where the no-filter path did not exclude unstitched entities while the with-filter path did. Signed-off-by: Fredrik Adelöw Made-with: Cursor --- .../catalog-backend/src/service/DefaultEntitiesCatalog.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 830120e486..29d9d915df 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -695,9 +695,9 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { // against one-row-per-entity rather than the much larger search // table. This keeps the facets aggregation fast even with many // filter clauses or permission conditions. - const entityIdSubquery = this.database('final_entities') - .select('final_entities.entity_id') - .whereNotNull('final_entities.final_entity'); + const entityIdSubquery = this.database('final_entities').select( + 'final_entities.entity_id', + ); applyEntityFilterToQuery({ filter: request.filter, From 88409600ba8270b45d5610a027e82f1d432768f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 20 Apr 2026 15:46:37 +0200 Subject: [PATCH 5/7] Revert to conditional final_entities constraint for facets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmarking on a production-like database shows that always applying the final_entities subquery (even without filters) causes a 2.6x regression on the no-filter path (5.2s -> 13.5s) due to ~530k memoized index lookups against final_entities. The FK cascade from search -> final_entities already guarantees search rows only exist for entities with a final_entities row, so the constraint is only needed when filters route through final_entities. Signed-off-by: Fredrik Adelöw Made-with: Cursor --- .../src/service/DefaultEntitiesCatalog.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 29d9d915df..5819708918 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -694,10 +694,12 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { // final_entities, so that the EXISTS-based filters correlate // against one-row-per-entity rather than the much larger search // table. This keeps the facets aggregation fast even with many - // filter clauses or permission conditions. - const entityIdSubquery = this.database('final_entities').select( - 'final_entities.entity_id', - ); + // filter clauses or permission conditions. The whereNotNull guard + // ensures that not-yet-stitched (or future tombstoned) entities + // are excluded from results. + const entityIdSubquery = this.database('final_entities') + .select('final_entities.entity_id') + .whereNotNull('final_entities.final_entity'); applyEntityFilterToQuery({ filter: request.filter, From f32d334034aeb6f2acf8ac0abd6f44e3bdb08980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 20 Apr 2026 16:14:03 +0200 Subject: [PATCH 6/7] Exclude unstitched entities from facets via NOT IN null set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of checking IS NOT NULL against ~520k rows (which adds a 2.6x regression on the no-filter path), exclude the tiny set of entities where final_entity IS NULL (~3,700 rows). The anti-join is nearly free and allows the guard to be applied unconditionally, so facets results consistently exclude not-yet-stitched (or future tombstoned) entities regardless of whether filters are present. Also addresses review feedback: - Add regression test for unstitched entity exclusion - Extract setupFacetsCatalog helper to reduce test boilerplate - Tighten assertions: use exact arrays instead of arrayContaining Signed-off-by: Fredrik Adelöw Made-with: Cursor --- .../service/DefaultEntitiesCatalog.test.ts | 341 ++++++++++-------- .../src/service/DefaultEntitiesCatalog.ts | 12 +- 2 files changed, 209 insertions(+), 144 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index ad931dca61..011edac291 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -2430,9 +2430,98 @@ describe('DefaultEntitiesCatalog', () => { }), ).resolves.toEqual({ facets: { - 'metadata.name': expect.arrayContaining([ - { value: 'one', count: 1 }, - ]), + 'metadata.name': [{ value: 'one', count: 1 }], + }, + }); + }, + ); + + async function setupFacetsCatalog( + databaseId: TestDatabaseId, + entities: Entity[], + ) { + await createDatabase(databaseId); + for (const entity of entities) { + await addEntityToSearch(entity); + } + return new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + } + + it.each(databases.eachSupportedId())( + 'excludes not-yet-stitched entities, %p', + async databaseId => { + await createDatabase(databaseId); + + await addEntityToSearch({ + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'stitched' }, + spec: {}, + }); + + // Insert an unstitched entity: final_entity is NULL but search + // rows exist. This simulates a race or future tombstone state. + const unstitchedId = v4(); + await knex('refresh_state').insert({ + entity_id: unstitchedId, + entity_ref: 'component:default/unstitched', + unprocessed_entity: '{}', + errors: '[]', + next_update_at: '2031-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }); + await knex('final_entities').insert({ + entity_id: unstitchedId, + entity_ref: 'component:default/unstitched', + hash: '', + }); + await knex('search').insert([ + { + entity_id: unstitchedId, + key: 'kind', + value: 'component', + original_value: 'Component', + }, + { + entity_id: unstitchedId, + key: 'metadata.name', + value: 'unstitched', + original_value: 'unstitched', + }, + ]); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + // Without filters: unstitched entity should be excluded + await expect( + catalog.facets({ + facets: ['metadata.name'], + credentials: mockCredentials.none(), + }), + ).resolves.toEqual({ + facets: { + 'metadata.name': [{ value: 'stitched', count: 1 }], + }, + }); + + // With filter: unstitched entity should also be excluded + await expect( + catalog.facets({ + facets: ['metadata.name'], + filter: { key: 'kind', values: ['component'] }, + credentials: mockCredentials.none(), + }), + ).resolves.toEqual({ + facets: { + 'metadata.name': [{ value: 'stitched', count: 1 }], }, }); }, @@ -2441,32 +2530,26 @@ describe('DefaultEntitiesCatalog', () => { it.each(databases.eachSupportedId())( 'filters with a predicate query, %p', async databaseId => { - await createDatabase(databaseId); - - await addEntityToSearch({ - apiVersion: 'a', - kind: 'Component', - metadata: { name: 'one' }, - spec: { type: 'service' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'Component', - metadata: { name: 'two' }, - spec: { type: 'library' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'API', - metadata: { name: 'three' }, - spec: { type: 'openapi' }, - }); - - const catalog = new DefaultEntitiesCatalog({ - database: knex, - logger: mockServices.logger.mock(), - stitcher, - }); + const catalog = await setupFacetsCatalog(databaseId, [ + { + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }, + { + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'two' }, + spec: { type: 'library' }, + }, + { + apiVersion: 'a', + kind: 'API', + metadata: { name: 'three' }, + spec: { type: 'openapi' }, + }, + ]); await expect( catalog.facets({ @@ -2476,10 +2559,10 @@ describe('DefaultEntitiesCatalog', () => { }), ).resolves.toEqual({ facets: { - 'spec.type': expect.arrayContaining([ - { value: 'service', count: 1 }, + 'spec.type': [ { value: 'library', count: 1 }, - ]), + { value: 'service', count: 1 }, + ], }, }); }, @@ -2488,32 +2571,26 @@ describe('DefaultEntitiesCatalog', () => { it.each(databases.eachSupportedId())( 'filters with a predicate query using $in, %p', async databaseId => { - await createDatabase(databaseId); - - await addEntityToSearch({ - apiVersion: 'a', - kind: 'Component', - metadata: { name: 'one' }, - spec: { type: 'service' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'API', - metadata: { name: 'two' }, - spec: { type: 'openapi' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'System', - metadata: { name: 'three' }, - spec: {}, - }); - - const catalog = new DefaultEntitiesCatalog({ - database: knex, - logger: mockServices.logger.mock(), - stitcher, - }); + const catalog = await setupFacetsCatalog(databaseId, [ + { + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }, + { + apiVersion: 'a', + kind: 'API', + metadata: { name: 'two' }, + spec: { type: 'openapi' }, + }, + { + apiVersion: 'a', + kind: 'System', + metadata: { name: 'three' }, + spec: {}, + }, + ]); await expect( catalog.facets({ @@ -2523,10 +2600,10 @@ describe('DefaultEntitiesCatalog', () => { }), ).resolves.toEqual({ facets: { - kind: expect.arrayContaining([ - { value: 'Component', count: 1 }, + kind: [ { value: 'API', count: 1 }, - ]), + { value: 'Component', count: 1 }, + ], }, }); }, @@ -2535,32 +2612,26 @@ describe('DefaultEntitiesCatalog', () => { it.each(databases.eachSupportedId())( 'filters with compound allOf filter, %p', async databaseId => { - await createDatabase(databaseId); - - await addEntityToSearch({ - apiVersion: 'a', - kind: 'Component', - metadata: { name: 'one' }, - spec: { type: 'service' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'Component', - metadata: { name: 'two' }, - spec: { type: 'library' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'API', - metadata: { name: 'three' }, - spec: { type: 'openapi' }, - }); - - const catalog = new DefaultEntitiesCatalog({ - database: knex, - logger: mockServices.logger.mock(), - stitcher, - }); + const catalog = await setupFacetsCatalog(databaseId, [ + { + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }, + { + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'two' }, + spec: { type: 'library' }, + }, + { + apiVersion: 'a', + kind: 'API', + metadata: { name: 'three' }, + spec: { type: 'openapi' }, + }, + ]); await expect( catalog.facets({ @@ -2584,32 +2655,26 @@ describe('DefaultEntitiesCatalog', () => { it.each(databases.eachSupportedId())( 'filters with compound anyOf filter, %p', async databaseId => { - await createDatabase(databaseId); - - await addEntityToSearch({ - apiVersion: 'a', - kind: 'Component', - metadata: { name: 'one' }, - spec: { type: 'service' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'API', - metadata: { name: 'two' }, - spec: { type: 'openapi' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'System', - metadata: { name: 'three' }, - spec: {}, - }); - - const catalog = new DefaultEntitiesCatalog({ - database: knex, - logger: mockServices.logger.mock(), - stitcher, - }); + const catalog = await setupFacetsCatalog(databaseId, [ + { + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }, + { + apiVersion: 'a', + kind: 'API', + metadata: { name: 'two' }, + spec: { type: 'openapi' }, + }, + { + apiVersion: 'a', + kind: 'System', + metadata: { name: 'three' }, + spec: {}, + }, + ]); await expect( catalog.facets({ @@ -2624,10 +2689,10 @@ describe('DefaultEntitiesCatalog', () => { }), ).resolves.toEqual({ facets: { - 'metadata.name': expect.arrayContaining([ + 'metadata.name': [ { value: 'one', count: 1 }, { value: 'two', count: 1 }, - ]), + ], }, }); }, @@ -2636,32 +2701,26 @@ describe('DefaultEntitiesCatalog', () => { it.each(databases.eachSupportedId())( 'filters with both filter and query combined, %p', async databaseId => { - await createDatabase(databaseId); - - await addEntityToSearch({ - apiVersion: 'a', - kind: 'Component', - metadata: { name: 'one' }, - spec: { type: 'service' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'Component', - metadata: { name: 'two' }, - spec: { type: 'library' }, - }); - await addEntityToSearch({ - apiVersion: 'a', - kind: 'API', - metadata: { name: 'three' }, - spec: { type: 'openapi' }, - }); - - const catalog = new DefaultEntitiesCatalog({ - database: knex, - logger: mockServices.logger.mock(), - stitcher, - }); + const catalog = await setupFacetsCatalog(databaseId, [ + { + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'one' }, + spec: { type: 'service' }, + }, + { + apiVersion: 'a', + kind: 'Component', + metadata: { name: 'two' }, + spec: { type: 'library' }, + }, + { + apiVersion: 'a', + kind: 'API', + metadata: { name: 'three' }, + spec: { type: 'openapi' }, + }, + ]); await expect( catalog.facets({ diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 5819708918..71a69b0100 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -676,12 +676,20 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { } async facets(request: EntityFacetsRequest): Promise { + // Exclude not-yet-stitched (or future tombstoned) entities by + // anti-joining the tiny set of null final_entity rows. This is + // nearly free (~3k rows) compared to confirming ~520k non-null rows. + const unstitchedSubquery = this.database('final_entities') + .select('final_entities.entity_id') + .whereNull('final_entities.final_entity'); + const query = this.database('search') .whereIn( 'search.key', request.facets.map(f => f.toLocaleLowerCase('en-US')), ) .whereNotNull('search.original_value') + .whereNotIn('search.entity_id', unstitchedSubquery) .select({ facet: 'search.key', value: 'search.original_value', @@ -694,9 +702,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { // final_entities, so that the EXISTS-based filters correlate // against one-row-per-entity rather than the much larger search // table. This keeps the facets aggregation fast even with many - // filter clauses or permission conditions. The whereNotNull guard - // ensures that not-yet-stitched (or future tombstoned) entities - // are excluded from results. + // filter clauses or permission conditions. const entityIdSubquery = this.database('final_entities') .select('final_entities.entity_id') .whereNotNull('final_entities.final_entity'); From 0ecb700addca54aaff2765a73207aaff897db39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 20 Apr 2026 17:31:00 +0200 Subject: [PATCH 7/7] Remove NOT IN unstitched guard from facets no-filter path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NOT IN guard against null final_entity rows was preventing the query planner from using parallel workers and (with a future covering index) index-only scans. Without the covering index it caused a 2.6x regression on the no-filter path (7s -> 18.4s). The filtered path already excludes unstitched entities via the whereNotNull('final_entities.final_entity') in the inner entityIdSubquery, so no guard is needed there. The no-filter path now matches 1.49.x behavior. A followup migration adding a covering index can re-introduce the guard efficiently. Signed-off-by: Fredrik Adelöw Made-with: Cursor --- .../src/service/DefaultEntitiesCatalog.test.ts | 17 +++-------------- .../src/service/DefaultEntitiesCatalog.ts | 12 ++---------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 011edac291..e714d6e3a7 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -2452,7 +2452,7 @@ describe('DefaultEntitiesCatalog', () => { } it.each(databases.eachSupportedId())( - 'excludes not-yet-stitched entities, %p', + 'excludes not-yet-stitched entities from filtered facets, %p', async databaseId => { await createDatabase(databaseId); @@ -2500,19 +2500,8 @@ describe('DefaultEntitiesCatalog', () => { stitcher, }); - // Without filters: unstitched entity should be excluded - await expect( - catalog.facets({ - facets: ['metadata.name'], - credentials: mockCredentials.none(), - }), - ).resolves.toEqual({ - facets: { - 'metadata.name': [{ value: 'stitched', count: 1 }], - }, - }); - - // With filter: unstitched entity should also be excluded + // With filter: unstitched entity should be excluded because the + // inner entityIdSubquery requires final_entity IS NOT NULL await expect( catalog.facets({ facets: ['metadata.name'], diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 71a69b0100..9a6fdd595b 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -676,20 +676,12 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { } async facets(request: EntityFacetsRequest): Promise { - // Exclude not-yet-stitched (or future tombstoned) entities by - // anti-joining the tiny set of null final_entity rows. This is - // nearly free (~3k rows) compared to confirming ~520k non-null rows. - const unstitchedSubquery = this.database('final_entities') - .select('final_entities.entity_id') - .whereNull('final_entities.final_entity'); - const query = this.database('search') .whereIn( 'search.key', request.facets.map(f => f.toLocaleLowerCase('en-US')), ) .whereNotNull('search.original_value') - .whereNotIn('search.entity_id', unstitchedSubquery) .select({ facet: 'search.key', value: 'search.original_value', @@ -701,8 +693,8 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { // Build a subquery that finds matching entity IDs via // final_entities, so that the EXISTS-based filters correlate // against one-row-per-entity rather than the much larger search - // table. This keeps the facets aggregation fast even with many - // filter clauses or permission conditions. + // table. The whereNotNull guard on final_entity excludes + // not-yet-stitched (or future tombstoned) entities. const entityIdSubquery = this.database('final_entities') .select('final_entities.entity_id') .whereNotNull('final_entities.final_entity');