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

View File

@ -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;
}

View File

@ -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],

View File

@ -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 });
});
});

View File

@ -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;
}
}

View File

@ -2,16 +2,11 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceFeatureFlagMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module';
import { LabResolver } from './lab.resolver';
@Module({
imports: [
FeatureFlagModule,
PermissionsModule,
WorkspaceFeatureFlagMapCacheModule,
],
imports: [FeatureFlagModule, PermissionsModule],
providers: [LabResolver],
exports: [],
})

View File

@ -2,7 +2,7 @@ import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
import { FeatureFlagException } from 'src/engine/core-modules/feature-flag/feature-flag.exception';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@ -13,23 +13,19 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
@UseGuards(SettingsPermissionsGuard(SettingPermissionType.WORKSPACE))
export class LabResolver {
constructor(
private featureFlagService: FeatureFlagService,
private workspaceFeatureFlagMapCacheService: WorkspaceFeatureFlagMapCacheService,
) {}
constructor(private featureFlagService: FeatureFlagService) {}
@UseGuards(WorkspaceAuthGuard)
@Mutation(() => FeatureFlag)
@Mutation(() => FeatureFlagDTO)
async updateLabPublicFeatureFlag(
@Args('input') input: UpdateLabPublicFeatureFlagInput,
@AuthWorkspace() workspace: Workspace,
): Promise<FeatureFlag> {
): Promise<FeatureFlagDTO> {
try {
const result = await this.featureFlagService.upsertWorkspaceFeatureFlag({
workspaceId: workspace.id,
@ -38,12 +34,6 @@ export class LabResolver {
shouldBePublic: true,
});
await this.workspaceFeatureFlagMapCacheService.recomputeFeatureFlagMapCache(
{
workspaceId: workspace.id,
},
);
return result;
} catch (error) {
if (error instanceof FeatureFlagException) {

View File

@ -21,8 +21,8 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
@ -160,8 +160,10 @@ export class WorkspaceResolver {
return `${paths[0]}?token=${workspaceLogoToken}`;
}
@ResolveField(() => [FeatureFlag], { nullable: true })
async featureFlags(@Parent() workspace: Workspace): Promise<FeatureFlag[]> {
@ResolveField(() => [FeatureFlagDTO], { nullable: true })
async featureFlags(
@Parent() workspace: Workspace,
): Promise<FeatureFlagDTO[]> {
const featureFlags = await this.featureFlagService.getWorkspaceFeatureFlags(
workspace.id,
);