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:
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user