Read feature flags from cache (#11556)

We are now storing a workspace's feature flag map in our redis cache. 
The cache is invalidated upon feature flag update through the lab
resolver.
This commit is contained in:
Marie
2025-04-14 17:31:13 +02:00
committed by GitHub
parent 15eb96337f
commit d4deca45e8
22 changed files with 323 additions and 248 deletions
@@ -0,0 +1,16 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Column } from 'typeorm';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ObjectType('FeatureFlagDTO')
export class FeatureFlagDTO {
@Field(() => FeatureFlagKey)
@Column({ nullable: false, type: 'text' })
key: FeatureFlagKey;
@Field()
@Column({ nullable: false })
value: boolean;
}
@@ -6,6 +6,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
@Module({
imports: [
@@ -15,6 +16,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
services: [],
resolvers: [],
}),
WorkspaceFeatureFlagsMapCacheModule,
],
exports: [FeatureFlagService],
providers: [FeatureFlagService],
@@ -10,6 +10,7 @@ import {
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
jest.mock(
'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate',
@@ -29,6 +30,11 @@ describe('FeatureFlagService', () => {
save: jest.fn(),
};
const mockWorkspaceFeatureFlagsMapCacheService = {
getWorkspaceFeatureFlagsMap: jest.fn(),
recomputeFeatureFlagsMapCache: jest.fn(),
};
const workspaceId = 'workspace-id';
const featureFlag = FeatureFlagKey.IsWorkflowEnabled;
@@ -47,6 +53,10 @@ describe('FeatureFlagService', () => {
provide: getRepositoryToken(FeatureFlag, 'core'),
useValue: mockFeatureFlagRepository,
},
{
provide: WorkspaceFeatureFlagsMapCacheService,
useValue: mockWorkspaceFeatureFlagsMapCacheService,
},
],
}).compile();
@@ -60,27 +70,29 @@ describe('FeatureFlagService', () => {
describe('isFeatureEnabled', () => {
it('should return true when feature flag is enabled', async () => {
// Prepare
mockFeatureFlagRepository.findOneBy.mockResolvedValue({
key: featureFlag,
value: true,
workspaceId,
});
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
{
[featureFlag]: true,
},
);
// Act
const result = await service.isFeatureEnabled(featureFlag, workspaceId);
// Assert
expect(result).toBe(true);
expect(mockFeatureFlagRepository.findOneBy).toHaveBeenCalledWith({
expect(
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap,
).toHaveBeenCalledWith({
workspaceId,
key: featureFlag,
value: true,
});
});
it('should return false when feature flag is not found', async () => {
// Prepare
mockFeatureFlagRepository.findOneBy.mockResolvedValue(null);
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
{},
);
// Act
const result = await service.isFeatureEnabled(featureFlag, workspaceId);
@@ -94,7 +106,6 @@ describe('FeatureFlagService', () => {
mockFeatureFlagRepository.findOneBy.mockResolvedValue({
key: featureFlag,
value: false,
workspaceId,
});
// Act
@@ -108,21 +119,25 @@ describe('FeatureFlagService', () => {
describe('getWorkspaceFeatureFlags', () => {
it('should return all feature flags for a workspace', async () => {
// Prepare
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap.mockResolvedValue(
{
[FeatureFlagKey.IsWorkflowEnabled]: true,
[FeatureFlagKey.IsCopilotEnabled]: false,
},
);
const mockFeatureFlags = [
{ key: FeatureFlagKey.IsWorkflowEnabled, value: true, workspaceId },
{ key: FeatureFlagKey.IsCopilotEnabled, value: false, workspaceId },
{ key: FeatureFlagKey.IsWorkflowEnabled, value: true },
{ key: FeatureFlagKey.IsCopilotEnabled, value: false },
];
mockFeatureFlagRepository.find.mockResolvedValue(mockFeatureFlags);
// Act
const result = await service.getWorkspaceFeatureFlags(workspaceId);
// Assert
expect(result).toEqual(mockFeatureFlags);
expect(mockFeatureFlagRepository.find).toHaveBeenCalledWith({
where: { workspaceId },
});
expect(
mockWorkspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap,
).toHaveBeenCalledWith({ workspaceId });
});
});
@@ -5,6 +5,7 @@ import { Repository } from 'typeorm';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import {
@@ -13,47 +14,46 @@ import {
} from 'src/engine/core-modules/feature-flag/feature-flag.exception';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
@Injectable()
export class FeatureFlagService {
constructor(
@InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>,
private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService,
) {}
public async isFeatureEnabled(
key: FeatureFlagKey,
workspaceId: string,
): Promise<boolean> {
const featureFlag = await this.featureFlagRepository.findOneBy({
workspaceId,
key,
value: true,
});
const featureFlagMap = await this.getWorkspaceFeatureFlagsMap(workspaceId);
return !!featureFlag?.value;
return !!featureFlagMap[key];
}
public async getWorkspaceFeatureFlags(
workspaceId: string,
): Promise<FeatureFlag[]> {
return this.featureFlagRepository.find({ where: { workspaceId } });
): Promise<FeatureFlagDTO[]> {
const workspaceFeatureFlagsMap =
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
{ workspaceId },
);
return Object.entries(workspaceFeatureFlagsMap).map(([key, value]) => ({
key: key as FeatureFlagKey,
value,
}));
}
public async getWorkspaceFeatureFlagsMap(
workspaceId: string,
): Promise<FeatureFlagMap> {
const workspaceFeatureFlags =
await this.getWorkspaceFeatureFlags(workspaceId);
const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce(
(result, currentFeatureFlag) => {
result[currentFeatureFlag.key] = currentFeatureFlag.value;
return result;
},
{} as FeatureFlagMap,
);
const workspaceFeatureFlagsMap =
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
{ workspaceId },
);
return workspaceFeatureFlagsMap;
}
@@ -62,13 +62,21 @@ export class FeatureFlagService {
keys: FeatureFlagKey[],
workspaceId: string,
): Promise<void> {
await this.featureFlagRepository.upsert(
keys.map((key) => ({ workspaceId, key, value: true })),
{
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
},
);
if (keys.length > 0) {
await this.featureFlagRepository.upsert(
keys.map((key) => ({ workspaceId, key, value: true })),
{
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
},
);
await this.workspaceFeatureFlagsMapCacheService.recomputeFeatureFlagsMapCache(
{
workspaceId: workspaceId,
},
);
}
}
public async upsertWorkspaceFeatureFlag({
@@ -120,6 +128,14 @@ export class FeatureFlagService {
workspaceId: workspaceId,
};
return await this.featureFlagRepository.save(featureFlagToSave);
const result = await this.featureFlagRepository.save(featureFlagToSave);
await this.workspaceFeatureFlagsMapCacheService.recomputeFeatureFlagsMapCache(
{
workspaceId: workspaceId,
},
);
return result;
}
}