diff --git a/plugins/catalog-backend/src/database/metrics.test.ts b/plugins/catalog-backend/src/database/metrics.test.ts index 33b1379a3a..74613dcc91 100644 --- a/plugins/catalog-backend/src/database/metrics.test.ts +++ b/plugins/catalog-backend/src/database/metrics.test.ts @@ -122,5 +122,41 @@ describe('metrics', () => { expect(Object.fromEntries(refreshed)).toEqual({ component: 2 }); }, ); + + it.each(databases.eachSupportedId())( + 'coalesces overlapping callers into a single underlying query, %p', + async databaseId => { + const knex = await createDatabase(databaseId); + const getCount = createEntitiesCountByKind(knex, { ttlMs: 50 }); + + await insertEntity(knex, { + entityRef: 'component:default/one', + finalEntity: '{}', + }); + + const finalEntitiesQueries: string[] = []; + knex.on('query', (q: { sql: string }) => { + if ( + /from\s+["`]?final_entities["`]?/i.test(q.sql) && + /^\s*select/i.test(q.sql) + ) { + finalEntitiesQueries.push(q.sql); + } + }); + + // Five concurrent callers should result in one query, not five. + const results = await Promise.all([ + getCount(), + getCount(), + getCount(), + getCount(), + getCount(), + ]); + expect(finalEntitiesQueries).toHaveLength(1); + for (const r of results) { + expect(Object.fromEntries(r)).toEqual({ component: 1 }); + } + }, + ); }); }); diff --git a/plugins/catalog-backend/src/database/metrics.ts b/plugins/catalog-backend/src/database/metrics.ts index 6b45a46dde..40a3c62b7a 100644 --- a/plugins/catalog-backend/src/database/metrics.ts +++ b/plugins/catalog-backend/src/database/metrics.ts @@ -22,15 +22,22 @@ import { MetricsService } from '@backstage/backend-plugin-api/alpha'; const ENTITIES_COUNT_TTL_MS = 30_000; /** - * Returns a function that produces a Map of entity kind -> count, using - * a short in-process TTL cache. + * Returns a function that produces a Map of entity kind -> count, with + * a single-flight cache that coalesces overlapping calls. * * The OpenTelemetry observable gauge and the legacy Prometheus gauge are * both registered to emit the same `catalog_entities_count` series, and - * both fire on every metrics scrape. Without the cache, that means two + * both fire on every metrics scrape. Without coalescing, that means two * identical heavy queries per scrape per pod, which can pile up against * the database faster than they complete. * + * Concurrent callers share the same in-flight promise, so a query is + * never overlapped by another instance of itself. The TTL is the + * minimum age at which the next caller is allowed to start a fresh + * query; if a query is still running when the TTL would have elapsed, + * waiting callers continue to share that one rather than starting a + * duplicate. + * * @internal exported for testing */ export function createEntitiesCountByKind( @@ -38,15 +45,25 @@ export function createEntitiesCountByKind( options?: { ttlMs?: number }, ): () => Promise> { const ttlMs = options?.ttlMs ?? ENTITIES_COUNT_TTL_MS; - let cache: { at: number; data: Map } | undefined; - return async () => { - const now = Date.now(); - if (cache && now - cache.at < ttlMs) { - return cache.data; + let inflight: Promise> | undefined; + let cached: { at: number; data: Map } | undefined; + return () => { + if (inflight) { + return inflight; } - const data = await queryEntitiesCountByKind(knex); - cache = { at: now, data }; - return data; + if (cached && Date.now() - cached.at < ttlMs) { + return Promise.resolve(cached.data); + } + inflight = (async () => { + try { + const data = await queryEntitiesCountByKind(knex); + cached = { at: Date.now(), data }; + return data; + } finally { + inflight = undefined; + } + })(); + return inflight; }; }