fix(catalog-backend): use INNER JOIN for filtered entity facets

Replace the `WHERE search.entity_id IN (...)` form with an INNER JOIN
against the filtered final_entities subquery in DefaultEntitiesCatalog#facets.
Results are unchanged; the planner gets more freedom to pick cheaper plans,
which on large catalogs leads to substantial speedups (1.2× to 7×+ in
adversarial testing on a ~13.8M-row search table) and avoids the
materialize-then-spill pattern of the IN form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
This commit is contained in:
Fredrik Adelöw
2026-05-08 11:17:08 +02:00
parent 4bb649d511
commit 3f55b73e32
2 changed files with 16 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Improved the performance of the entity facets endpoint when filters are applied. The filtered entity set is now combined with the search table through an inner join rather than a `WHERE entity_id IN (subquery)`. Results are unchanged; on large catalogs the query planner is able to choose dramatically cheaper plans, with measured improvements ranging from roughly 1.2× on already-fast cases to 7× or more on high-cardinality facets.
@@ -707,7 +707,17 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
knex: this.database,
});
query.whereIn('search.entity_id', entityIdSubquery);
// Use INNER JOIN rather than `WHERE search.entity_id IN (...)`. The
// results are the same but the JOIN form gives the planner more
// freedom in join shape and ordering. On PostgreSQL with large
// search tables, the IN form tends to materialize the full filtered
// entity set up front and spill to temp; the JOIN form lets the
// planner pick a much cheaper plan based on actual selectivities.
query.innerJoin(
entityIdSubquery.as('filtered_entities'),
'search.entity_id',
'filtered_entities.entity_id',
);
}
const rows = await query;