permission: cache user info and parallelize resolution
The permission backend previously resolved userInfo and minted a plugin request token sequentially for every authorize request with user credentials. On high-traffic endpoints this meant two serial internal HTTP round-trips per request, even when the same user made many requests in quick succession. This change: 1. Adds a 5-second TTL cache to DefaultUserInfoService so that repeated getUserInfo() calls for the same user return the cached result without an HTTP call to the auth backend. 2. Parallelises the getUserInfo() and getPluginRequestToken() calls in the permission backend's handleRequest via Promise.all, saving one sequential round-trip on cache misses. Signed-off-by: Fredrik Adelöw <freben@gmail.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstageUserInfo,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { mockCredentials } from '@backstage/backend-test-utils';
|
||||
import { CachedUserInfoService } from './CachedUserInfoService';
|
||||
|
||||
const aliceInfo: BackstageUserInfo = {
|
||||
userEntityRef: 'user:default/alice',
|
||||
ownershipEntityRefs: ['user:default/alice', 'group:default/team-a'],
|
||||
};
|
||||
|
||||
describe('CachedUserInfoService', () => {
|
||||
it('delegates to the underlying service on the first call', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
|
||||
const result = await service.getUserInfo(mockCredentials.user());
|
||||
|
||||
expect(result).toEqual(aliceInfo);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the cached result on subsequent calls within TTL', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
await service.getUserInfo(creds);
|
||||
await service.getUserInfo(creds);
|
||||
await service.getUserInfo(creds);
|
||||
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('coalesces concurrent in-flight requests for the same token', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
service.getUserInfo(creds),
|
||||
service.getUserInfo(creds),
|
||||
service.getUserInfo(creds),
|
||||
]);
|
||||
|
||||
expect(r1).toEqual(aliceInfo);
|
||||
expect(r2).toEqual(aliceInfo);
|
||||
expect(r3).toEqual(aliceInfo);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('caches different tokens separately', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
|
||||
await service.getUserInfo(mockCredentials.user('user:default/alice'));
|
||||
await service.getUserInfo(mockCredentials.user('user:default/bob'));
|
||||
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-fetches after the TTL expires', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate, { ttlMs: 50 });
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
await service.getUserInfo(creds);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
|
||||
await service.getUserInfo(creds);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('evicts the cache entry on rejection and retries on next call', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('auth backend down'))
|
||||
.mockResolvedValueOnce(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
await expect(service.getUserInfo(creds)).rejects.toThrow(
|
||||
'auth backend down',
|
||||
);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
const result = await service.getUserInfo(creds);
|
||||
expect(result).toEqual(aliceInfo);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('evicts eagerly so concurrent waiters see the rejection and the next call retries', async () => {
|
||||
let rejectFirst: (error: Error) => void;
|
||||
const firstCall = new Promise<BackstageUserInfo>((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
});
|
||||
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstCall)
|
||||
.mockResolvedValueOnce(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
const p1 = service.getUserInfo(creds);
|
||||
const p2 = service.getUserInfo(creds);
|
||||
|
||||
rejectFirst!(new Error('boom'));
|
||||
|
||||
await expect(p1).rejects.toThrow('boom');
|
||||
await expect(p2).rejects.toThrow('boom');
|
||||
|
||||
const result = await service.getUserInfo(creds);
|
||||
expect(result).toEqual(aliceInfo);
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('delegates directly when credentials have no token', async () => {
|
||||
const delegate: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const service = new CachedUserInfoService(delegate);
|
||||
|
||||
await service.getUserInfo(mockCredentials.none());
|
||||
await service.getUserInfo(mockCredentials.none());
|
||||
|
||||
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares cache entries across instances when given the same map', async () => {
|
||||
const delegate1: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const delegate2: UserInfoService = {
|
||||
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
|
||||
};
|
||||
const entries = new Map();
|
||||
const service1 = new CachedUserInfoService(delegate1, { entries });
|
||||
const service2 = new CachedUserInfoService(delegate2, { entries });
|
||||
const creds = mockCredentials.user();
|
||||
|
||||
await service1.getUserInfo(creds);
|
||||
await service2.getUserInfo(creds);
|
||||
|
||||
expect(delegate1.getUserInfo).toHaveBeenCalledTimes(1);
|
||||
expect(delegate2.getUserInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstageCredentials,
|
||||
BackstageUserInfo,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { toInternalBackstageCredentials } from '../auth/helpers';
|
||||
|
||||
const DEFAULT_TTL_MS = 5_000;
|
||||
const SWEEP_INTERVAL_MS = 30_000;
|
||||
|
||||
export type UserInfoCacheEntry = {
|
||||
promise: Promise<BackstageUserInfo>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export class CachedUserInfoService implements UserInfoService {
|
||||
readonly #delegate: UserInfoService;
|
||||
readonly #entries: Map<string, UserInfoCacheEntry>;
|
||||
readonly #ttlMs: number;
|
||||
#lastSweep: number = Date.now();
|
||||
|
||||
constructor(
|
||||
delegate: UserInfoService,
|
||||
options?: {
|
||||
entries?: Map<string, UserInfoCacheEntry>;
|
||||
ttlMs?: number;
|
||||
},
|
||||
) {
|
||||
this.#delegate = delegate;
|
||||
this.#entries = options?.entries ?? new Map();
|
||||
this.#ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
||||
}
|
||||
|
||||
async getUserInfo(
|
||||
credentials: BackstageCredentials,
|
||||
): Promise<BackstageUserInfo> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
const token = internalCredentials.token;
|
||||
if (!token) {
|
||||
return this.#delegate.getUserInfo(credentials);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const cached = this.#entries.get(token);
|
||||
if (cached) {
|
||||
if (cached.expiresAt > now) {
|
||||
return cached.promise;
|
||||
}
|
||||
this.#entries.delete(token);
|
||||
}
|
||||
|
||||
if (now - this.#lastSweep > SWEEP_INTERVAL_MS) {
|
||||
this.#lastSweep = now;
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const promise = this.#delegate.getUserInfo(credentials).catch(error => {
|
||||
this.#entries.delete(token);
|
||||
throw error;
|
||||
});
|
||||
|
||||
this.#entries.set(token, {
|
||||
promise,
|
||||
expiresAt: now + this.#ttlMs,
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
CachedUserInfoService,
|
||||
UserInfoCacheEntry,
|
||||
} from './CachedUserInfoService';
|
||||
import { DefaultUserInfoService } from './DefaultUserInfoService';
|
||||
|
||||
/**
|
||||
@@ -34,7 +38,13 @@ export const userInfoServiceFactory = createServiceFactory({
|
||||
deps: {
|
||||
discovery: coreServices.discovery,
|
||||
},
|
||||
async factory({ discovery }) {
|
||||
return new DefaultUserInfoService({ discovery });
|
||||
createRootContext() {
|
||||
return new Map<string, UserInfoCacheEntry>();
|
||||
},
|
||||
async factory({ discovery }, entries) {
|
||||
return new CachedUserInfoService(
|
||||
new DefaultUserInfoService({ discovery }),
|
||||
{ entries },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user