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:
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: [],
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user