1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +02:00

do etag calculation when memoizing feature toggles

To prevent express from having to do etag calculation of all feature
toggle responses even if hitting a memoized feature toggle this PR adds
etag calculation to the memoized function. This will cost us an extra
etag calculation for client SDK requests without ETag headers, but for
SDK requests with ETag headers, it should reduce the number of etag
calculations we do down to 1 per 0.6 seconds per query

Co-authored-by: Gard Rimestad <gard@getunleash.io>
Co-authored-by: Nuno Gois <nuno@getunleash.ai>
This commit is contained in:
Christopher Kolstad 2022-12-20 15:02:07 +01:00 committed by Nuno Góis
parent 7ce5b3de64
commit 6e650289c8
4 changed files with 90 additions and 8 deletions

View File

@ -99,6 +99,7 @@
"db-migrate-shared": "1.2.0", "db-migrate-shared": "1.2.0",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"errorhandler": "^1.5.1", "errorhandler": "^1.5.1",
"etag": "^1.8.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^6.6.0", "express-rate-limit": "^6.6.0",
"express-session": "^1.17.1", "express-session": "^1.17.1",

View File

@ -1,7 +1,7 @@
import memoizee from 'memoizee'; import memoizee from 'memoizee';
import { Response } from 'express'; import { Response } from 'express';
import Controller from '../controller'; import Controller from '../controller';
import { IUnleashConfig, IUnleashServices } from '../../types'; import { IFlagResolver, IUnleashConfig, IUnleashServices } from '../../types';
import FeatureToggleService from '../../services/feature-toggle-service'; import FeatureToggleService from '../../services/feature-toggle-service';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { querySchema } from '../../schema/feature-schema'; import { querySchema } from '../../schema/feature-schema';
@ -25,14 +25,14 @@ import {
clientFeaturesSchema, clientFeaturesSchema,
ClientFeaturesSchema, ClientFeaturesSchema,
} from '../../openapi/spec/client-features-schema'; } from '../../openapi/spec/client-features-schema';
import etag from 'etag';
const version = 2; const version = 2;
interface QueryOverride { interface QueryOverride {
project?: string[]; project?: string[];
environment?: string; environment?: string;
} }
const FEATURE_TOGGLE_MEMOIZED_ETAGS = 'clientFeaturesMemoizedEtags';
export default class FeatureController extends Controller { export default class FeatureController extends Controller {
private readonly logger: Logger; private readonly logger: Logger;
@ -48,6 +48,8 @@ export default class FeatureController extends Controller {
private cachedFeatures: any; private cachedFeatures: any;
private seenEtags: Map<string, string>;
constructor( constructor(
{ {
featureToggleServiceV2, featureToggleServiceV2,
@ -64,12 +66,13 @@ export default class FeatureController extends Controller {
config: IUnleashConfig, config: IUnleashConfig,
) { ) {
super(config); super(config);
const { clientFeatureCaching } = config; const { clientFeatureCaching, flagResolver } = config;
this.featureToggleServiceV2 = featureToggleServiceV2; this.featureToggleServiceV2 = featureToggleServiceV2;
this.segmentService = segmentService; this.segmentService = segmentService;
this.clientSpecService = clientSpecService; this.clientSpecService = clientSpecService;
this.openApiService = openApiService; this.openApiService = openApiService;
this.logger = config.getLogger('client-api/feature.js'); this.logger = config.getLogger('client-api/feature.js');
this.seenEtags = new Map<string, string>();
this.route({ this.route({
method: 'get', method: 'get',
@ -106,7 +109,7 @@ export default class FeatureController extends Controller {
if (clientFeatureCaching?.enabled) { if (clientFeatureCaching?.enabled) {
this.cache = true; this.cache = true;
this.cachedFeatures = memoizee( this.cachedFeatures = memoizee(
(query) => this.resolveFeaturesAndSegments(query), (query) => this.resolveFeaturesAndSegments(query, flagResolver),
{ {
promise: true, promise: true,
maxAge: clientFeatureCaching.maxAge, maxAge: clientFeatureCaching.maxAge,
@ -116,16 +119,53 @@ export default class FeatureController extends Controller {
}, },
}, },
); );
this.logger.info('Cached features was enabled');
if (flagResolver.isEnabled(FEATURE_TOGGLE_MEMOIZED_ETAGS)) {
this.logger.info('Memoized etags was enabled');
this.route({
method: 'get',
path: '',
handler: this.getAllCached,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'getAllClientFeatures',
tags: ['Client'],
responses: {
200: createResponseSchema(
'clientFeaturesSchema',
),
},
}),
],
});
}
} }
} }
private async resolveFeaturesAndSegments( private async resolveFeaturesAndSegments(
query?: IFeatureToggleQuery, query?: IFeatureToggleQuery,
flagResolver?: IFlagResolver,
): Promise<[FeatureConfigurationClient[], ISegment[]]> { ): Promise<[FeatureConfigurationClient[], ISegment[]]> {
return Promise.all([ return Promise.all([
this.featureToggleServiceV2.getClientFeatures(query), this.featureToggleServiceV2.getClientFeatures(query),
this.segmentService.getActive(), this.segmentService.getActive(),
]); ]).then((data) => {
if (flagResolver?.isEnabled(FEATURE_TOGGLE_MEMOIZED_ETAGS)) {
this.seenEtags.set(
JSON.stringify(query),
etag(
JSON.stringify({
version,
features: data[0],
query: { ...query },
segments: data[1],
}),
),
);
}
return data;
});
} }
private async resolveQuery( private async resolveQuery(
@ -138,7 +178,7 @@ export default class FeatureController extends Controller {
if (!isAllProjects(user.projects)) { if (!isAllProjects(user.projects)) {
override.project = user.projects; override.project = user.projects;
} }
if (user.environment !== ALL) { if (user.environment != ALL) {
override.environment = user.environment; override.environment = user.environment;
} }
} }
@ -195,12 +235,44 @@ export default class FeatureController extends Controller {
return query; return query;
} }
async getAllCached(
req: IAuthRequest,
res: Response<ClientFeaturesSchema>,
): Promise<void> {
const query = await this.resolveQuery(req);
const [features, segments] = await this.cachedFeatures(query);
const modifiedSince = req.header('If-None-Match').substring(2);
this.logger.debug(`ETag header from Client: ${modifiedSince}`);
const cached = this.seenEtags.get(JSON.stringify(query));
this.logger.debug(`ETag header from memoizee ${cached}`);
if (modifiedSince !== undefined && modifiedSince === cached) {
// We have a match. Return 304
res.status(304);
res.end();
return;
}
if (this.clientSpecService.requestSupportsSpec(req, 'segments')) {
this.openApiService.respondWithValidation(
200,
res,
clientFeaturesSchema.$id,
{ version, features, query: { ...query }, segments },
);
} else {
this.openApiService.respondWithValidation(
200,
res,
clientFeaturesSchema.$id,
{ version, features, query },
);
}
}
async getAll( async getAll(
req: IAuthRequest, req: IAuthRequest,
res: Response<ClientFeaturesSchema>, res: Response<ClientFeaturesSchema>,
): Promise<void> { ): Promise<void> {
const query = await this.resolveQuery(req); const query = await this.resolveQuery(req);
const [features, segments] = this.cache const [features, segments] = this.cache
? await this.cachedFeatures(query) ? await this.cachedFeatures(query)
: await this.resolveFeaturesAndSegments(query); : await this.resolveFeaturesAndSegments(query);

View File

@ -47,6 +47,10 @@ const flags = {
process.env.UNLEASH_EXPERIMENTAL_MAINTENANCE, process.env.UNLEASH_EXPERIMENTAL_MAINTENANCE,
false, false,
), ),
clientFeaturesMemoizedEtags: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_CLIENT_FEATURES_MEMOIZED_ETAGS,
false,
),
}; };
export const defaultExperimentalOptions: IExperimentalOptions = { export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -3345,6 +3345,11 @@ esutils@^2.0.2:
resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
etag@^1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
etag@~1.8.1: etag@~1.8.1:
version "1.8.1" version "1.8.1"
resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"