coalesce overlapping callers via single-flight in-flight promise

Hold a shared promise rather than just a resolved value. Concurrent
callers awaiting a fresh count get the same in-flight promise back, so
the underlying query is never overlapped by a duplicate. The TTL is now
the minimum gap between the resolution of one query and the start of
the next, rather than a hard bound on cache age.

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:55:26 +02:00
parent ccbad9d892
commit 8f7f591c46
2 changed files with 64 additions and 11 deletions
@@ -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 });
}
},
);
});
});
+28 -11
View File
@@ -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<Map<string, number>> {
const ttlMs = options?.ttlMs ?? ENTITIES_COUNT_TTL_MS;
let cache: { at: number; data: Map<string, number> } | undefined;
return async () => {
const now = Date.now();
if (cache && now - cache.at < ttlMs) {
return cache.data;
let inflight: Promise<Map<string, number>> | undefined;
let cached: { at: number; data: Map<string, number> } | 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;
};
}