1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: make edge use token's cache (#6893)

## About the changes
This PR removes the feature flag `queryMissingTokens` that was fully
rolled out.
It introduces a new way of checking edgeValidTokens controlled by the
flag `checkEdgeValidTokensFromCache` that relies in the cached data but
hits the DB if needed.

The assumption is that most of the times edge will find tokens in the
cache, except for a few cases in which a new token is queried. From all
tokens we expect at most one to hit the DB and in this case querying a
single token should be better than querying all the tokens.
This commit is contained in:
Gastón Fournier 2024-04-19 15:40:15 +02:00 committed by GitHub
parent ff6297d338
commit 126b78896e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 147 additions and 138 deletions

View File

@ -194,10 +194,12 @@ export class ApiTokenStore implements IApiTokenStore {
} }
async get(key: string): Promise<IApiToken> { async get(key: string): Promise<IApiToken> {
const stopTimer = this.timer('get-by-secret');
const row = await this.makeTokenProjectQuery().where( const row = await this.makeTokenProjectQuery().where(
'tokens.secret', 'tokens.secret',
key, key,
); );
stopTimer();
return toTokens(row)[0]; return toTokens(row)[0];
} }

View File

@ -49,7 +49,7 @@ export const scheduleServices = async (
apiTokenService.fetchActiveTokens.bind(apiTokenService), apiTokenService.fetchActiveTokens.bind(apiTokenService),
minutesToMilliseconds(1), minutesToMilliseconds(1),
'fetchActiveTokens', 'fetchActiveTokens',
0, 0, // no jitter, we need tokens at startup
); );
schedulerService.schedule( schedulerService.schedule(

View File

@ -2,7 +2,6 @@ import { ApiTokenType } from '../types/models/api-token';
import type { IUnleashConfig } from '../types/option'; import type { IUnleashConfig } from '../types/option';
import type { IApiRequest, IAuthRequest } from '../routes/unleash-types'; import type { IApiRequest, IAuthRequest } from '../routes/unleash-types';
import type { IUnleashServices } from '../server-impl'; import type { IUnleashServices } from '../server-impl';
import type { IFlagContext } from '../types';
const isClientApi = ({ path }) => { const isClientApi = ({ path }) => {
return path && path.indexOf('/api/client') > -1; return path && path.indexOf('/api/client') > -1;
@ -27,20 +26,6 @@ const isProxyApi = ({ path }) => {
); );
}; };
const contextFrom = (
req: IAuthRequest<any, any, any, any> | IApiRequest<any, any, any, any>,
): IFlagContext | undefined => {
// this is what we'd get from edge:
// req_path: '/api/client/features',
// req_user_agent: 'unleash-edge-16.0.4'
return {
reqPath: req.path,
reqUserAgent: req.get ? req.get('User-Agent') ?? '' : '',
reqAppName:
req.headers?.['unleash-appname'] ?? req.query?.appName ?? '',
};
};
export const TOKEN_TYPE_ERROR_MESSAGE = export const TOKEN_TYPE_ERROR_MESSAGE =
'invalid token: expected a different token type for this endpoint'; 'invalid token: expected a different token type for this endpoint';
@ -70,10 +55,7 @@ const apiAccessMiddleware = (
const apiToken = req.header('authorization'); const apiToken = req.header('authorization');
if (!apiToken?.startsWith('user:')) { if (!apiToken?.startsWith('user:')) {
const apiUser = apiToken const apiUser = apiToken
? await apiTokenService.getUserForToken( ? await apiTokenService.getUserForToken(apiToken)
apiToken,
contextFrom(req),
)
: undefined; : undefined;
const { CLIENT, FRONTEND } = ApiTokenType; const { CLIENT, FRONTEND } = ApiTokenType;

View File

@ -197,17 +197,17 @@ test('getUserForToken should get a user with admin token user id and token name'
expect(user!.internalAdminTokenUserId).toBe(ADMIN_TOKEN_USER.id); expect(user!.internalAdminTokenUserId).toBe(ADMIN_TOKEN_USER.id);
}); });
describe('When token is added by another instance', () => { describe('API token getTokenWithCache', () => {
const setup = (options?: IUnleashOptions) => { const token: IApiTokenCreate = {
const token: IApiTokenCreate = { environment: 'default',
environment: 'default', projects: ['*'],
projects: ['*'], secret: '*:*:some-random-string',
secret: '*:*:some-random-string', type: ApiTokenType.CLIENT,
type: ApiTokenType.CLIENT, tokenName: 'new-token-by-another-instance',
tokenName: 'new-token-by-another-instance', expiresAt: undefined,
expiresAt: undefined, };
};
const setup = (options?: IUnleashOptions) => {
const config: IUnleashConfig = createTestConfig(options); const config: IUnleashConfig = createTestConfig(options);
const apiTokenStore = new FakeApiTokenStore(); const apiTokenStore = new FakeApiTokenStore();
const environmentStore = new FakeEnvironmentStore(); const environmentStore = new FakeEnvironmentStore();
@ -220,60 +220,43 @@ describe('When token is added by another instance', () => {
return { return {
apiTokenService, apiTokenService,
apiTokenStore, apiTokenStore,
token,
}; };
}; };
test('should not return the token when query db flag is disabled', async () => {
const { apiTokenService, apiTokenStore, token } = setup();
// simulate this token being inserted by another instance (apiTokenService does not know about it) test('should return the token and perform only one db query', async () => {
const { apiTokenService, apiTokenStore } = setup();
const apiTokenStoreGet = jest.spyOn(apiTokenStore, 'get');
// valid token not present in cache (could be inserted by another instance)
apiTokenStore.insert(token); apiTokenStore.insert(token);
const found = await apiTokenService.getUserForToken(token.secret); for (let i = 0; i < 5; i++) {
expect(found).toBeUndefined(); const found = await apiTokenService.getTokenWithCache(token.secret);
}); expect(found).toBeDefined();
expect(found?.tokenName).toBe(token.tokenName);
test('should return the token when query db flag is enabled', async () => { expect(found?.createdAt).toBeDefined();
const { apiTokenService, apiTokenStore, token } = setup({ }
experimental: { expect(apiTokenStoreGet).toHaveBeenCalledTimes(1);
flags: {
queryMissingTokens: true,
},
},
});
// simulate this token being inserted by another instance (apiTokenService does not know about it)
apiTokenStore.insert(token);
const found = await apiTokenService.getUserForToken(token.secret);
expect(found).toBeDefined();
expect(found?.username).toBe(token.tokenName);
}); });
test('should query the db only once for invalid tokens', async () => { test('should query the db only once for invalid tokens', async () => {
jest.useFakeTimers(); jest.useFakeTimers();
const { apiTokenService, apiTokenStore } = setup({ const { apiTokenService, apiTokenStore } = setup();
experimental: {
flags: {
queryMissingTokens: true,
},
},
});
const apiTokenStoreGet = jest.spyOn(apiTokenStore, 'get'); const apiTokenStoreGet = jest.spyOn(apiTokenStore, 'get');
const invalidToken = 'invalid-token'; const invalidToken = 'invalid-token';
for (let i = 0; i < 10; i++) { for (let i = 0; i < 5; i++) {
expect( expect(
await apiTokenService.getUserForToken(invalidToken), await apiTokenService.getTokenWithCache(invalidToken),
).toBeUndefined(); ).toBeUndefined();
} }
expect(apiTokenStoreGet).toHaveBeenCalledTimes(1); expect(apiTokenStoreGet).toHaveBeenCalledTimes(1);
// after more than 5 minutes we should be able to query again // after more than 5 minutes we should be able to query again
jest.advanceTimersByTime(minutesToMilliseconds(6)); jest.advanceTimersByTime(minutesToMilliseconds(6));
for (let i = 0; i < 10; i++) { for (let i = 0; i < 5; i++) {
expect( expect(
await apiTokenService.getUserForToken(invalidToken), await apiTokenService.getTokenWithCache(invalidToken),
).toBeUndefined(); ).toBeUndefined();
} }
expect(apiTokenStoreGet).toHaveBeenCalledTimes(2); expect(apiTokenStoreGet).toHaveBeenCalledTimes(2);

View File

@ -25,13 +25,14 @@ import {
ApiTokenDeletedEvent, ApiTokenDeletedEvent,
ApiTokenUpdatedEvent, ApiTokenUpdatedEvent,
type IAuditUser, type IAuditUser,
type IFlagContext,
type IFlagResolver, type IFlagResolver,
SYSTEM_USER_AUDIT, SYSTEM_USER_AUDIT,
} from '../types'; } from '../types';
import { omitKeys } from '../util'; import { omitKeys } from '../util';
import type EventService from '../features/events/event-service'; import type EventService from '../features/events/event-service';
import { addMinutes, isPast } from 'date-fns'; import { addMinutes, isPast } from 'date-fns';
import metricsHelper from '../util/metrics-helper';
import { FUNCTION_TIME } from '../metric-events';
const resolveTokenPermissions = (tokenType: string) => { const resolveTokenPermissions = (tokenType: string) => {
if (tokenType === ApiTokenType.ADMIN) { if (tokenType === ApiTokenType.ADMIN) {
@ -60,14 +61,14 @@ export class ApiTokenService {
private queryAfter = new Map<string, Date>(); private queryAfter = new Map<string, Date>();
private initialized = false;
private eventService: EventService; private eventService: EventService;
private lastSeenSecrets: Set<string> = new Set<string>(); private lastSeenSecrets: Set<string> = new Set<string>();
private flagResolver: IFlagResolver; private flagResolver: IFlagResolver;
private timer: Function;
constructor( constructor(
{ {
apiTokenStore, apiTokenStore,
@ -75,7 +76,7 @@ export class ApiTokenService {
}: Pick<IUnleashStores, 'apiTokenStore' | 'environmentStore'>, }: Pick<IUnleashStores, 'apiTokenStore' | 'environmentStore'>,
config: Pick< config: Pick<
IUnleashConfig, IUnleashConfig,
'getLogger' | 'authentication' | 'flagResolver' 'getLogger' | 'authentication' | 'flagResolver' | 'eventBus'
>, >,
eventService: EventService, eventService: EventService,
) { ) {
@ -94,18 +95,21 @@ export class ApiTokenService {
this.initApiTokens(config.authentication.initApiTokens), this.initApiTokens(config.authentication.initApiTokens),
); );
} }
this.timer = (functionName: string) =>
metricsHelper.wrapTimer(config.eventBus, FUNCTION_TIME, {
className: 'ApiTokenService',
functionName,
});
} }
/** /**
* Executed by a scheduler to refresh all active tokens * Called by a scheduler without jitter to refresh all active tokens
*/ */
async fetchActiveTokens(): Promise<void> { async fetchActiveTokens(): Promise<void> {
try { try {
this.activeTokens = await this.store.getAllActive(); this.activeTokens = await this.store.getAllActive();
this.initialized = true; } catch (e) {
} finally { this.logger.warn('Failed to fetch active tokens', e);
// biome-ignore lint/correctness/noUnsafeFinally: We ignored this for eslint. Leaving this here for now, server-impl test fails without it
return;
} }
} }
@ -113,50 +117,7 @@ export class ApiTokenService {
return this.store.get(secret); return this.store.get(secret);
} }
async updateLastSeen(): Promise<void> { async getTokenWithCache(secret: string): Promise<IApiToken | undefined> {
if (this.lastSeenSecrets.size > 0) {
const toStore = [...this.lastSeenSecrets];
this.lastSeenSecrets = new Set<string>();
await this.store.markSeenAt(toStore);
}
}
public async getAllTokens(): Promise<IApiToken[]> {
return this.store.getAll();
}
public async getAllActiveTokens(): Promise<IApiToken[]> {
if (this.flagResolver.isEnabled('useMemoizedActiveTokens')) {
if (!this.initialized) {
// unlikely this will happen but nice to have a fail safe
this.logger.info('Fetching active tokens before initialized');
await this.fetchActiveTokens();
}
return this.activeTokens;
} else {
return this.store.getAllActive();
}
}
private async initApiTokens(tokens: ILegacyApiTokenCreate[]) {
const tokenCount = await this.store.count();
if (tokenCount > 0) {
return;
}
try {
const createAll = tokens
.map(mapLegacyTokenWithSecret)
.map((t) => this.insertNewApiToken(t, SYSTEM_USER_AUDIT));
await Promise.all(createAll);
} catch (e) {
this.logger.error('Unable to create initial Admin API tokens');
}
}
public async getUserForToken(
secret: string,
flagContext?: IFlagContext, // temporarily added, expected from the middleware
): Promise<IApiUser | undefined> {
if (!secret) { if (!secret) {
return undefined; return undefined;
} }
@ -178,11 +139,7 @@ export class ApiTokenService {
} }
const nextAllowedQuery = this.queryAfter.get(secret) ?? 0; const nextAllowedQuery = this.queryAfter.get(secret) ?? 0;
if ( if (!token && isPast(nextAllowedQuery)) {
!token &&
isPast(nextAllowedQuery) &&
this.flagResolver.isEnabled('queryMissingTokens', flagContext)
) {
if (this.queryAfter.size > 1000) { if (this.queryAfter.size > 1000) {
// establish a max limit for queryAfter size to prevent memory leak // establish a max limit for queryAfter size to prevent memory leak
this.queryAfter.clear(); this.queryAfter.clear();
@ -190,12 +147,52 @@ export class ApiTokenService {
// prevent querying the same invalid secret multiple times. Expire after 5 minutes // prevent querying the same invalid secret multiple times. Expire after 5 minutes
this.queryAfter.set(secret, addMinutes(new Date(), 5)); this.queryAfter.set(secret, addMinutes(new Date(), 5));
const stopCacheTimer = this.timer('getTokenWithCache.query');
token = await this.store.get(secret); token = await this.store.get(secret);
if (token) { if (token) {
this.activeTokens.push(token); this.activeTokens.push(token);
} }
stopCacheTimer();
} }
return token;
}
async updateLastSeen(): Promise<void> {
if (this.lastSeenSecrets.size > 0) {
const toStore = [...this.lastSeenSecrets];
this.lastSeenSecrets = new Set<string>();
await this.store.markSeenAt(toStore);
}
}
public async getAllTokens(): Promise<IApiToken[]> {
return this.store.getAll();
}
public async getAllActiveTokens(): Promise<IApiToken[]> {
return this.store.getAllActive();
}
private async initApiTokens(tokens: ILegacyApiTokenCreate[]) {
const tokenCount = await this.store.count();
if (tokenCount > 0) {
return;
}
try {
const createAll = tokens
.map(mapLegacyTokenWithSecret)
.map((t) => this.insertNewApiToken(t, SYSTEM_USER_AUDIT));
await Promise.all(createAll);
} catch (e) {
this.logger.error('Unable to create initial Admin API tokens');
}
}
public async getUserForToken(
secret: string,
): Promise<IApiUser | undefined> {
const token = await this.getTokenWithCache(secret);
if (token) { if (token) {
this.lastSeenSecrets.add(token.secret); this.lastSeenSecrets.add(token.secret);
const apiUser: IApiUser = new ApiUser({ const apiUser: IApiUser = new ApiUser({

View File

@ -1,39 +1,83 @@
import type { IUnleashConfig } from '../types'; import type { IFlagResolver, IUnleashConfig } from '../types';
import type { Logger } from '../logger'; import type { Logger } from '../logger';
import type { EdgeTokenSchema } from '../openapi/spec/edge-token-schema'; import type { EdgeTokenSchema } from '../openapi/spec/edge-token-schema';
import { constantTimeCompare } from '../util/constantTimeCompare'; import { constantTimeCompare } from '../util/constantTimeCompare';
import type { ValidatedEdgeTokensSchema } from '../openapi/spec/validated-edge-tokens-schema'; import type { ValidatedEdgeTokensSchema } from '../openapi/spec/validated-edge-tokens-schema';
import type { ApiTokenService } from './api-token-service'; import type { ApiTokenService } from './api-token-service';
import metricsHelper from '../util/metrics-helper';
import { FUNCTION_TIME } from '../metric-events';
export default class EdgeService { export default class EdgeService {
private logger: Logger; private logger: Logger;
private apiTokenService: ApiTokenService; private apiTokenService: ApiTokenService;
private flagResolver: IFlagResolver;
private timer: Function;
constructor( constructor(
{ apiTokenService }: { apiTokenService: ApiTokenService }, { apiTokenService }: { apiTokenService: ApiTokenService },
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>, {
getLogger,
flagResolver,
eventBus,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver' | 'eventBus'>,
) { ) {
this.logger = getLogger('lib/services/edge-service.ts'); this.logger = getLogger('lib/services/edge-service.ts');
this.apiTokenService = apiTokenService; this.apiTokenService = apiTokenService;
this.flagResolver = flagResolver;
this.timer = (functionName: string) =>
metricsHelper.wrapTimer(eventBus, FUNCTION_TIME, {
className: 'EdgeService',
functionName,
});
} }
async getValidTokens(tokens: string[]): Promise<ValidatedEdgeTokensSchema> { async getValidTokens(tokens: string[]): Promise<ValidatedEdgeTokensSchema> {
const activeTokens = await this.apiTokenService.getAllActiveTokens(); if (this.flagResolver.isEnabled('checkEdgeValidTokensFromCache')) {
const edgeTokens = tokens.reduce((result: EdgeTokenSchema[], token) => { const stopTimer = this.timer('validateTokensWithCache');
const dbToken = activeTokens.find((activeToken) => // new behavior: use cached tokens when possible
constantTimeCompare(activeToken.secret, token), // use the db to fetch the missing ones
); // cache stores both missing and active so we don't hammer the db
if (dbToken) { const validatedTokens: EdgeTokenSchema[] = [];
result.push({ for (const token of tokens) {
token: token, const found =
type: dbToken.type, await this.apiTokenService.getTokenWithCache(token);
projects: dbToken.projects, if (found) {
}); validatedTokens.push({
token: token,
type: found.type,
projects: found.projects,
});
}
} }
return result; stopTimer();
}, []); return { tokens: validatedTokens };
return { tokens: edgeTokens }; } else {
// old behavior: go to the db to fetch all tokens and then filter in memory
const stopTimer = this.timer('validateTokensWithoutCache');
const activeTokens =
await this.apiTokenService.getAllActiveTokens();
const edgeTokens = tokens.reduce(
(result: EdgeTokenSchema[], token) => {
const dbToken = activeTokens.find((activeToken) =>
constantTimeCompare(activeToken.secret, token),
);
if (dbToken) {
result.push({
token: token,
type: dbToken.type,
projects: dbToken.projects,
});
}
return result;
},
[],
);
stopTimer();
return { tokens: edgeTokens };
}
} }
} }

View File

@ -43,6 +43,7 @@ export type IFlagKey =
| 'displayTrafficDataUsage' | 'displayTrafficDataUsage'
| 'useMemoizedActiveTokens' | 'useMemoizedActiveTokens'
| 'queryMissingTokens' | 'queryMissingTokens'
| 'checkEdgeValidTokensFromCache'
| 'userAccessUIEnabled' | 'userAccessUIEnabled'
| 'disableUpdateMaxRevisionId' | 'disableUpdateMaxRevisionId'
| 'disablePublishUnannouncedEvents' | 'disablePublishUnannouncedEvents'