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

feat: first revision of delta api (#8967)

This is not changing existing logic.
We are creating a new endpoint, which is guarded behind a flag.

---------

Co-authored-by: Simon Hornby <liquidwicked64@gmail.com>
Co-authored-by: FredrikOseberg <fredrik.no@gmail.com>
This commit is contained in:
Jaanus Sellin 2024-12-12 14:18:11 +02:00 committed by GitHub
parent fe8308da1f
commit 59bdfcd84b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 832 additions and 3 deletions

View File

@ -0,0 +1,181 @@
import { calculateRequiredClientRevision } from './client-feature-toggle-cache';
const mockAdd = (params): any => {
const base = {
name: 'feature',
project: 'default',
stale: false,
type: 'release',
enabled: true,
strategies: [],
variants: [],
description: 'A feature',
impressionData: [],
dependencies: [],
};
return { ...base, ...params };
};
test('compresses multiple revisions to a single update', () => {
const revisionList = [
{
revisionId: 1,
updated: [mockAdd({ type: 'release' })],
removed: [],
},
{
revisionId: 2,
updated: [mockAdd({ type: 'test' })],
removed: [],
},
];
const revisions = calculateRequiredClientRevision(revisionList, 0, [
'default',
]);
expect(revisions).toEqual({
revisionId: 2,
updated: [mockAdd({ type: 'test' })],
removed: [],
});
});
test('revision that adds, removes then adds again does not end up with the remove', () => {
const revisionList = [
{
revisionId: 1,
updated: [mockAdd({ name: 'some-toggle' })],
removed: [],
},
{
revisionId: 2,
updated: [],
removed: [
{
name: 'some-toggle',
project: 'default',
},
],
},
{
revisionId: 3,
updated: [mockAdd({ name: 'some-toggle' })],
removed: [],
},
];
const revisions = calculateRequiredClientRevision(revisionList, 0, [
'default',
]);
expect(revisions).toEqual({
revisionId: 3,
updated: [mockAdd({ name: 'some-toggle' })],
removed: [],
});
});
test('revision that removes, adds then removes again does not end up with the remove', () => {
const revisionList = [
{
revisionId: 1,
updated: [],
removed: [
{
name: 'some-toggle',
project: 'default',
},
],
},
{
revisionId: 2,
updated: [mockAdd({ name: 'some-toggle' })],
removed: [],
},
{
revisionId: 3,
updated: [],
removed: [
{
name: 'some-toggle',
project: 'default',
},
],
},
];
const revisions = calculateRequiredClientRevision(revisionList, 0, [
'default',
]);
expect(revisions).toEqual({
revisionId: 3,
updated: [],
removed: [
{
name: 'some-toggle',
project: 'default',
},
],
});
});
test('revision equal to the base case returns only later revisions ', () => {
const revisionList = [
{
revisionId: 1,
updated: [
mockAdd({ name: 'feature1' }),
mockAdd({ name: 'feature2' }),
mockAdd({ name: 'feature3' }),
],
removed: [],
},
{
revisionId: 2,
updated: [mockAdd({ name: 'feature4' })],
removed: [],
},
{
revisionId: 3,
updated: [mockAdd({ name: 'feature5' })],
removed: [],
},
];
const revisions = calculateRequiredClientRevision(revisionList, 1, [
'default',
]);
expect(revisions).toEqual({
revisionId: 3,
updated: [mockAdd({ name: 'feature4' }), mockAdd({ name: 'feature5' })],
removed: [],
});
});
test('project filter removes features not in project', () => {
const revisionList = [
{
revisionId: 1,
updated: [mockAdd({ name: 'feature1', project: 'project1' })],
removed: [],
},
{
revisionId: 2,
updated: [mockAdd({ name: 'feature2', project: 'project2' })],
removed: [],
},
];
const revisions = calculateRequiredClientRevision(revisionList, 0, [
'project1',
]);
expect(revisions).toEqual({
revisionId: 2,
updated: [mockAdd({ name: 'feature1', project: 'project1' })],
removed: [],
});
});

View File

@ -0,0 +1,330 @@
import type {
IEventStore,
IFeatureToggleClient,
IFeatureToggleClientStore,
IFeatureToggleQuery,
} from '../../../types';
import type { FeatureConfigurationClient } from '../../feature-toggle/types/feature-toggle-strategies-store-type';
import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
import { RevisionCache } from './revision-cache';
type DeletedFeature = {
name: string;
project: string;
};
export type ClientFeatureChange = {
updated: IFeatureToggleClient[];
removed: DeletedFeature[];
revisionId: number;
};
export type Revision = {
revisionId: number;
updated: any[];
removed: DeletedFeature[];
};
type Revisions = Record<string, RevisionCache>;
const applyRevision = (first: Revision, last: Revision): Revision => {
const updatedMap = new Map(
[...first.updated, ...last.updated].map((feature) => [
feature.name,
feature,
]),
);
const removedMap = new Map(
[...first.removed, ...last.removed].map((feature) => [
feature.name,
feature,
]),
);
for (const feature of last.removed) {
updatedMap.delete(feature.name);
}
for (const feature of last.updated) {
removedMap.delete(feature.name);
}
return {
revisionId: last.revisionId,
updated: Array.from(updatedMap.values()),
removed: Array.from(removedMap.values()),
};
};
const filterRevisionByProject = (
revision: Revision,
projects: string[],
): Revision => {
const updated = revision.updated.filter(
(feature) =>
projects.includes('*') || projects.includes(feature.project),
);
const removed = revision.removed.filter(
(feature) =>
projects.includes('*') || projects.includes(feature.project),
);
return { ...revision, updated, removed };
};
export const calculateRequiredClientRevision = (
revisions: Revision[],
requiredRevisionId: number,
projects: string[],
) => {
const targetedRevisions = revisions.filter(
(revision) => revision.revisionId > requiredRevisionId,
);
console.log('targeted revisions', targetedRevisions);
const projectFeatureRevisions = targetedRevisions.map((revision) =>
filterRevisionByProject(revision, projects),
);
return projectFeatureRevisions.reduce(applyRevision);
};
export class ClientFeatureToggleCache {
private clientFeatureToggleStore: IFeatureToggleClientStore;
private cache: Revisions = {};
private eventStore: IEventStore;
private currentRevisionId: number = 0;
private interval: NodeJS.Timer;
private configurationRevisionService: ConfigurationRevisionService;
constructor(
clientFeatureToggleStore: IFeatureToggleClientStore,
eventStore: IEventStore,
configurationRevisionService: ConfigurationRevisionService,
) {
this.eventStore = eventStore;
this.configurationRevisionService = configurationRevisionService;
this.clientFeatureToggleStore = clientFeatureToggleStore;
this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this);
this.initCache();
this.configurationRevisionService.on(
UPDATE_REVISION,
this.onUpdateRevisionEvent,
);
}
async getDelta(
sdkRevisionId: number | undefined,
environment: string,
projects: string[],
): Promise<ClientFeatureChange | undefined> {
const requiredRevisionId = sdkRevisionId || 0;
// Should get the latest state if revision does not exist or if sdkRevision is not present
// We should be able to do this without going to the database by merging revisions from the cache with
// the base case
if (
!sdkRevisionId ||
(sdkRevisionId &&
sdkRevisionId !== this.currentRevisionId &&
!this.cache[environment].hasRevision(sdkRevisionId))
) {
return {
revisionId: this.currentRevisionId,
// @ts-ignore
updated: await this.getClientFeatures({ environment }),
removed: [],
};
}
if (requiredRevisionId >= this.currentRevisionId) {
return undefined;
}
const environmentRevisions = this.cache[environment].getRevisions();
const compressedRevision = calculateRequiredClientRevision(
environmentRevisions,
requiredRevisionId,
projects,
);
return Promise.resolve(compressedRevision);
}
private async onUpdateRevisionEvent() {
await this.pollEvents();
}
public async pollEvents() {
const latestRevision =
await this.configurationRevisionService.getMaxRevisionId();
if (this.currentRevisionId === 0) {
await this.populateBaseCache(latestRevision);
} else {
const changeEvents = await this.eventStore.getRevisionRange(
this.currentRevisionId,
latestRevision,
);
const changedToggles = [
...new Set(
changeEvents
.filter((event) => event.featureName)
.map((event) => event.featureName!),
),
];
const newToggles = await this.getChangedToggles(
changedToggles,
latestRevision, // TODO: this should come back from the same query to not be out of sync
);
if (this.cache.development) {
this.cache.development.addRevision(newToggles);
}
}
this.currentRevisionId = latestRevision;
}
private async populateBaseCache(latestRevisionId: number) {
const features = await this.getClientFeatures({
environment: 'development',
});
if (this.cache.development) {
this.cache.development.addRevision({
updated: features as any, //impressionData is not on the type but should be
removed: [],
revisionId: latestRevisionId,
});
}
console.log(`Populated base cache with ${features.length} features`);
}
async getChangedToggles(
toggles: string[],
revisionId: number,
): Promise<ClientFeatureChange> {
const foundToggles = await this.getClientFeatures({
// @ts-ignore removed toggleNames from the type, we should not use this method at all,
toggleNames: toggles,
environment: 'development',
});
const foundToggleNames = foundToggles.map((toggle) => toggle.name);
const removed = toggles
.filter((toggle) => !foundToggleNames.includes(toggle))
.map((name) => ({
name,
project: 'default', // TODO: this needs to be smart and figure out the project . IMPORTANT
}));
return {
updated: foundToggles as any, // impressionData is not on the type but should be
removed,
revisionId,
};
}
// TODO: I think we should remove it as is, because we do not need initialized cache, I think we should populate cache on demand for each env
// also we already have populateBaseCache method
public async initCache() {
//TODO: This only returns stuff for the default environment!!! Need to pass a query to get the relevant environment
// featuresByEnvironment cache
// The base cache is a record of <environment, array>
// Each array holds a collection of objects that contains the revisionId and which
// flags changed in each revision. It also holds a type that informs us whether or not
// the revision is the base case or if is an update or remove operation
// To get the base for each cache we need to get all features for all environments and the max revision id
// hardcoded for now
// const environments = ["default", "development", "production"];
const defaultBaseFeatures = await this.getClientFeatures({
environment: 'default',
});
const developmentBaseFeatures = await this.getClientFeatures({
environment: 'development',
});
const productionBaseFeatures = await this.getClientFeatures({
environment: 'production',
});
const defaultCache = new RevisionCache([
{
revisionId: this.currentRevisionId,
updated: [defaultBaseFeatures],
removed: [],
},
]);
const developmentCache = new RevisionCache([
{
revisionId: this.currentRevisionId,
updated: [developmentBaseFeatures],
removed: [],
},
]);
const productionCache = new RevisionCache([
{
revisionId: this.currentRevisionId,
updated: [productionBaseFeatures],
removed: [],
},
]);
// Always assume that the first item of the array is the base
const cache = {
default: defaultCache,
development: developmentCache,
production: productionCache,
};
const latestRevision =
await this.configurationRevisionService.getMaxRevisionId();
this.currentRevisionId = latestRevision;
this.cache = cache;
}
async getClientFeatures(
query?: IFeatureToggleQuery,
): Promise<FeatureConfigurationClient[]> {
const result = await this.clientFeatureToggleStore.getClient(
query || {},
);
return result.map(
({
name,
type,
enabled,
project,
stale,
strategies,
variants,
description,
impressionData,
dependencies,
}) => ({
name,
type,
enabled,
project,
stale,
strategies,
variants,
description,
impressionData,
dependencies,
}),
);
}
}

View File

@ -0,0 +1,33 @@
import { ClientFeatureToggleCache } from './client-feature-toggle-cache';
import EventStore from '../../events/event-store';
import ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
import type { IUnleashConfig } from '../../../types';
import type { Db } from '../../../db/db';
import FeatureToggleClientStore from '../client-feature-toggle-store';
export const createClientFeatureToggleCache = (
db: Db,
config: IUnleashConfig,
): ClientFeatureToggleCache => {
const { getLogger, eventBus, flagResolver } = config;
const eventStore = new EventStore(db, getLogger);
const featureToggleClientStore = new FeatureToggleClientStore(
db,
eventBus,
getLogger,
flagResolver,
);
const configurationRevisionService =
ConfigurationRevisionService.getInstance({ eventStore }, config);
const clientFeatureToggleCache = new ClientFeatureToggleCache(
featureToggleClientStore,
eventStore,
configurationRevisionService,
);
return clientFeatureToggleCache;
};

View File

@ -0,0 +1,110 @@
import { RevisionCache } from './revision-cache';
import type { Revision } from './client-feature-toggle-cache';
describe('RevisionCache', () => {
it('should create a new base when trying to add a new revision at the max limit', () => {
const initialRevisions: Revision[] = [
{
revisionId: 1,
updated: [
{
name: 'test-flag',
type: 'release',
enabled: false,
project: 'default',
stale: false,
strategies: [
{
name: 'flexibleRollout',
constraints: [],
parameters: {
groupId: 'test-flag',
rollout: '100',
stickiness: 'default',
},
variants: [],
},
],
variants: [],
description: null,
impressionData: false,
},
],
removed: [],
},
{
revisionId: 2,
updated: [
{
name: 'my-feature-flag',
type: 'release',
enabled: true,
project: 'default',
stale: false,
strategies: [
{
name: 'flexibleRollout',
constraints: [],
parameters: {
groupId: 'my-feature-flag',
rollout: '100',
stickiness: 'default',
},
variants: [],
},
],
variants: [],
description: null,
impressionData: false,
},
],
removed: [],
},
];
const maxLength = 2;
const deltaCache = new RevisionCache(initialRevisions, maxLength);
// Add a new revision to trigger changeBase
deltaCache.addRevision({
revisionId: 3,
updated: [
{
name: 'another-feature-flag',
type: 'release',
enabled: true,
project: 'default',
stale: false,
strategies: [
{
name: 'flexibleRollout',
constraints: [],
parameters: {
groupId: 'another-feature-flag',
rollout: '100',
stickiness: 'default',
},
variants: [],
},
],
variants: [],
description: null,
impressionData: false,
},
],
removed: [],
});
const revisions = deltaCache.getRevisions();
// Check that the base has been changed and merged correctly
expect(revisions.length).toBe(2);
expect(revisions[0].updated.length).toBe(2);
expect(revisions[0].updated).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'test-flag' }),
expect.objectContaining({ name: 'my-feature-flag' }),
]),
);
});
});

View File

@ -0,0 +1,48 @@
import type { Revision } from './client-feature-toggle-cache';
const mergeWithoutDuplicates = (arr1: any[], arr2: any[]) => {
const map = new Map();
arr1.concat(arr2).forEach((item) => {
map.set(item.name, item);
});
return Array.from(map.values());
};
export class RevisionCache {
private cache: Revision[];
private maxLength: number;
constructor(data: Revision[] = [], maxLength: number = 100) {
this.cache = data;
this.maxLength = maxLength;
}
public addRevision(revision: Revision): void {
if (this.cache.length >= this.maxLength) {
this.changeBase();
}
this.cache = [...this.cache, revision];
}
public getRevisions(): Revision[] {
return this.cache;
}
public hasRevision(revisionId: number): boolean {
return this.cache.some(
(revision) => revision.revisionId === revisionId,
);
}
private changeBase(): void {
if (!(this.cache.length >= 2)) return;
const base = this.cache[0];
const newBase = this.cache[1];
newBase.removed = mergeWithoutDuplicates(base.removed, newBase.removed);
newBase.updated = mergeWithoutDuplicates(base.updated, newBase.updated);
this.cache = [newBase, ...this.cache.slice(2)];
}
}

View File

@ -9,6 +9,10 @@ import type {
import type { Logger } from '../../logger';
import type { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
import type {
ClientFeatureChange,
ClientFeatureToggleCache,
} from './cache/client-feature-toggle-cache';
export class ClientFeatureToggleService {
private logger: Logger;
@ -17,15 +21,19 @@ export class ClientFeatureToggleService {
private segmentReadModel: ISegmentReadModel;
private clientFeatureToggleCache: ClientFeatureToggleCache | null = null;
constructor(
{
clientFeatureToggleStore,
}: Pick<IUnleashStores, 'clientFeatureToggleStore'>,
segmentReadModel: ISegmentReadModel,
clientFeatureToggleCache: ClientFeatureToggleCache | null,
{ getLogger }: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
) {
this.logger = getLogger('services/client-feature-toggle-service.ts');
this.segmentReadModel = segmentReadModel;
this.clientFeatureToggleCache = clientFeatureToggleCache;
this.clientFeatureToggleStore = clientFeatureToggleStore;
}
@ -33,6 +41,24 @@ export class ClientFeatureToggleService {
return this.segmentReadModel.getActiveForClient();
}
async getClientDelta(
revisionId: number | undefined,
projects: string[],
environment: string,
): Promise<ClientFeatureChange | undefined> {
if (this.clientFeatureToggleCache !== null) {
return this.clientFeatureToggleCache.getDelta(
revisionId,
environment,
projects,
);
} else {
throw new Error(
'Calling the partial updates but the cache is not initialized',
);
}
}
async getClientFeatures(
query?: IFeatureToggleQuery,
): Promise<FeatureConfigurationClient[]> {

View File

@ -185,7 +185,6 @@ export default class FeatureToggleClientStore
);
}
}
const rows = await query;
stopTimer();

View File

@ -33,6 +33,7 @@ import {
} from '../../openapi/spec/client-features-schema';
import type ConfigurationRevisionService from '../feature-toggle/configuration-revision-service';
import type { ClientFeatureToggleService } from './client-feature-toggle-service';
import type { ClientFeatureChange } from './cache/client-feature-toggle-cache';
const version = 2;
@ -94,6 +95,25 @@ export default class FeatureController extends Controller {
this.flagResolver = config.flagResolver;
this.logger = config.getLogger('client-api/feature.js');
this.route({
method: 'get',
path: '/delta',
handler: this.getDelta,
permission: NONE,
middleware: [
openApiService.validPath({
summary: 'Get partial updates (SDK)',
description:
'Initially returns the full set of feature flags available to the provided API key. When called again with the returned etag, only returns the flags that have changed',
operationId: 'getDelta',
tags: ['Unstable'],
responses: {
200: createResponseSchema('clientFeaturesSchema'),
},
}),
],
});
this.route({
method: 'get',
path: '/:featureName',
@ -278,6 +298,46 @@ export default class FeatureController extends Controller {
}
}
async getDelta(
req: IAuthRequest,
res: Response<ClientFeatureChange>,
): Promise<void> {
if (!this.flagResolver.isEnabled('deltaApi')) {
throw new NotFoundError();
}
const query = await this.resolveQuery(req);
const etag = req.headers['if-none-match'];
const meta = await this.calculateMeta(query);
const currentSdkRevisionId = etag ? Number.parseInt(etag) : undefined;
const projects = query.project ? query.project : ['*'];
const environment = query.environment ? query.environment : 'default';
const changedFeatures =
await this.clientFeatureToggleService.getClientDelta(
currentSdkRevisionId,
projects,
environment,
);
if (!changedFeatures) {
res.status(304);
res.getHeaderNames().forEach((header) => res.removeHeader(header));
res.end();
return;
}
if (changedFeatures.revisionId === currentSdkRevisionId) {
res.status(304);
res.getHeaderNames().forEach((header) => res.removeHeader(header));
res.end();
return;
}
res.setHeader('ETag', changedFeatures.revisionId.toString());
res.send(changedFeatures);
}
async calculateMeta(query: IFeatureToggleQuery): Promise<IMeta> {
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
const revisionId =

View File

@ -5,6 +5,7 @@ import FakeClientFeatureToggleStore from './fakes/fake-client-feature-toggle-sto
import { ClientFeatureToggleService } from './client-feature-toggle-service';
import { SegmentReadModel } from '../segment/segment-read-model';
import { FakeSegmentReadModel } from '../segment/fake-segment-read-model';
import { createClientFeatureToggleCache } from './cache/createClientFeatureToggleCache';
export const createClientFeatureToggleService = (
db: Db,
@ -21,11 +22,14 @@ export const createClientFeatureToggleService = (
const segmentReadModel = new SegmentReadModel(db);
const clientFeatureToggleCache = createClientFeatureToggleCache(db, config);
const clientFeatureToggleService = new ClientFeatureToggleService(
{
clientFeatureToggleStore: featureToggleClientStore,
},
segmentReadModel,
clientFeatureToggleCache,
{ getLogger, flagResolver },
);
@ -46,6 +50,7 @@ export const createFakeClientFeatureToggleService = (
clientFeatureToggleStore: fakeClientFeatureToggleStore,
},
fakeSegmentReadModel,
null,
{ getLogger, flagResolver },
);

View File

@ -201,6 +201,27 @@ class EventStore implements IEventStore {
return row?.max ?? 0;
}
async getRevisionRange(start: number, end: number): Promise<IEvent[]> {
const query = this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.where('id', '>', start)
.andWhere('id', '<=', end)
.andWhere((builder) =>
builder
.whereNotNull('feature_name')
.orWhereIn('type', [
SEGMENT_UPDATED,
FEATURE_IMPORT,
FEATURES_IMPORTED,
]),
)
.orderBy('id', 'asc');
const rows = await query;
return rows.map(this.rowToEvent);
}
async delete(key: number): Promise<void> {
await this.db(TABLE).where({ id: key }).del();
}

View File

@ -1,5 +1,6 @@
import type { Logger } from '../../logger';
import type {
IEvent,
IEventStore,
IFlagResolver,
IUnleashConfig,
@ -72,13 +73,17 @@ export default class ConfigurationRevisionService extends EventEmitter {
'Updating feature configuration with new revision Id',
revisionId,
);
this.emit(UPDATE_REVISION, revisionId);
this.revisionId = revisionId;
this.emit(UPDATE_REVISION, revisionId);
}
return this.revisionId;
}
async getRevisionRange(start: number, end: number): Promise<IEvent[]> {
return this.eventStore.getRevisionRange(start, end);
}
destroy(): void {
ConfigurationRevisionService.instance?.removeAllListeners();
}

View File

@ -47,6 +47,7 @@ async function createApp(
const stores = createStores(config, db);
await compareAndLogPostgresVersion(config, stores.settingStore);
const services = createServices(stores, config, db);
if (!config.disableScheduler) {
await scheduleServices(services, config);
}

View File

@ -62,7 +62,8 @@ export type IFlagKey =
| 'granularAdminPermissions'
| 'streaming'
| 'etagVariant'
| 'oidcRedirect';
| 'oidcRedirect'
| 'deltaApi';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -295,6 +296,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_OIDC_REDIRECT,
false,
),
deltaApi: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_DELTA_API,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -43,6 +43,7 @@ export interface IEventStore
queryParams: IQueryParam[],
): Promise<IEvent[]>;
getMaxRevisionId(currentMax?: number): Promise<number>;
getRevisionRange(start: number, end: number): Promise<IEvent[]>;
query(operations: IQueryOperations[]): Promise<IEvent[]>;
queryCount(operations: IQueryOperations[]): Promise<number>;
setCreatedByUserId(batchSize: number): Promise<number | undefined>;

View File

@ -57,6 +57,7 @@ process.nextTick(async () => {
flagOverviewRedesign: false,
licensedUsers: true,
granularAdminPermissions: true,
deltaApi: true,
},
},
authentication: {

View File

@ -17,6 +17,9 @@ class FakeEventStore implements IEventStore {
this.eventEmitter.setMaxListeners(0);
this.events = [];
}
getRevisionRange(start: number, end: number): Promise<IEvent[]> {
throw new Error('Method not implemented.');
}
getProjectRecentEventActivity(
project: string,