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

@ -529,6 +529,12 @@ export type FeatureFlag = {
workspaceId: Scalars['String'];
};
export type FeatureFlagDto = {
__typename?: 'FeatureFlagDTO';
key: FeatureFlagKey;
value: Scalars['Boolean'];
};
export enum FeatureFlagKey {
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
@ -889,7 +895,7 @@ export type Mutation = {
submitFormStep: Scalars['Boolean'];
switchToYearlyInterval: BillingUpdateOutput;
track: Analytics;
updateLabPublicFeatureFlag: FeatureFlag;
updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneField: Field;
updateOneObject: Object;
updateOneRole: Role;
@ -2239,7 +2245,7 @@ export type Workspace = {
defaultRole?: Maybe<Role>;
deletedAt?: Maybe<Scalars['DateTime']>;
displayName?: Maybe<Scalars['String']>;
featureFlags?: Maybe<Array<FeatureFlag>>;
featureFlags?: Maybe<Array<FeatureFlagDto>>;
hasValidEnterpriseKey: Scalars['Boolean'];
id: Scalars['UUID'];
inviteHash?: Maybe<Scalars['String']>;
@ -2660,7 +2666,7 @@ export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
}>;
export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean } };
export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean } };
export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean };
@ -2759,7 +2765,7 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -2776,7 +2782,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@ -3090,10 +3096,8 @@ export const UserQueryFragmentFragmentDoc = gql`
customUrl
}
featureFlags {
id
key
value
workspaceId
}
metadataVersion
currentBillingSubscription {
@ -4796,7 +4800,6 @@ export type GetSystemHealthStatusQueryResult = Apollo.QueryResult<GetSystemHealt
export const UpdateLabPublicFeatureFlagDocument = gql`
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
updateLabPublicFeatureFlag(input: $input) {
id
key
value
}

View File

@ -5,7 +5,6 @@ export const UPDATE_LAB_PUBLIC_FEATURE_FLAG = gql`
$input: UpdateLabPublicFeatureFlagInput!
) {
updateLabPublicFeatureFlag(input: $input) {
id
key
value
}

View File

@ -2,11 +2,11 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
FeatureFlagKey,
useUpdateLabPublicFeatureFlagMutation,
} from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
export const useLabPublicFeatureFlags = () => {
const [error, setError] = useState<string | null>(null);
@ -28,7 +28,6 @@ export const useLabPublicFeatureFlags = () => {
) ?? []),
{
...updatedFlag,
workspaceId: currentWorkspace.id,
},
],
});

View File

@ -43,10 +43,8 @@ export const USER_QUERY_FRAGMENT = gql`
customUrl
}
featureFlags {
id
key
value
workspaceId
}
metadataVersion
currentBillingSubscription {

View File

@ -59,22 +59,16 @@ export const mockCurrentWorkspace: Workspace = {
isMicrosoftAuthEnabled: false,
featureFlags: [
{
id: '1492de61-5018-4368-8923-4f1eeaf988c4',
key: FeatureFlagKey.IsAirtableIntegrationEnabled,
value: true,
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
},
{
id: '1492de61-5018-4368-8923-4f1eeaf988c5',
key: FeatureFlagKey.IsPostgreSQLIntegrationEnabled,
value: true,
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
},
{
id: '1492de61-5018-4368-8923-4f1eeaf988c6',
key: FeatureFlagKey.IsWorkflowEnabled,
value: true,
workspaceId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
},
],
createdAt: '2023-04-26T10:23:42.33625+00:00',

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,
);

View File

@ -1,47 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class WorkspaceFeatureFlagMapCacheService {
logger = new Logger(WorkspaceFeatureFlagMapCacheService.name);
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly featureFlagService: FeatureFlagService,
) {}
async recomputeFeatureFlagMapCache({
workspaceId,
ignoreLock = false,
}: {
workspaceId: string;
ignoreLock?: boolean;
}): Promise<void> {
const isAlreadyCaching =
await this.workspaceCacheStorageService.getFeatureFlagMapOngoingCachingLock(
workspaceId,
);
if (!ignoreLock && isAlreadyCaching) {
return;
}
await this.workspaceCacheStorageService.addFeatureFlagMapOngoingCachingLock(
workspaceId,
);
const freshFeatureFlagMap =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId);
await this.workspaceCacheStorageService.setFeatureFlagMap(
workspaceId,
freshFeatureFlagMap,
);
await this.workspaceCacheStorageService.removeFeatureFlagMapOngoingCachingLock(
workspaceId,
);
}
}

View File

@ -1,18 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
WorkspaceCacheStorageModule,
FeatureFlagModule,
],
providers: [WorkspaceFeatureFlagMapCacheService],
exports: [WorkspaceFeatureFlagMapCacheService],
})
export class WorkspaceFeatureFlagMapCacheModule {}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace, FeatureFlag], 'core'),
WorkspaceCacheStorageModule,
],
providers: [WorkspaceFeatureFlagsMapCacheService],
exports: [WorkspaceFeatureFlagsMapCacheService],
})
export class WorkspaceFeatureFlagsMapCacheModule {}

View File

@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class WorkspaceFeatureFlagsMapCacheService {
logger = new Logger(WorkspaceFeatureFlagsMapCacheService.name);
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
@InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>,
) {}
async getWorkspaceFeatureFlagsMap({
workspaceId,
}: {
workspaceId: string;
}): Promise<FeatureFlagMap> {
const { data: workspaceFeatureFlagsMap } =
await this.getWorkspaceFeatureFlagsMapAndVersion({ workspaceId });
return workspaceFeatureFlagsMap;
}
async getWorkspaceFeatureFlagsMapAndVersion({
workspaceId,
}: {
workspaceId: string;
}) {
return getFromCacheWithRecompute<string, FeatureFlagMap>({
workspaceId,
getCacheData: () =>
this.workspaceCacheStorageService.getFeatureFlagsMap(workspaceId),
getCacheVersion: () =>
this.workspaceCacheStorageService.getFeatureFlagsMapVersionFromCache(
workspaceId,
),
recomputeCache: (params) => this.recomputeFeatureFlagsMapCache(params),
cachedEntityName: 'Feature flag map',
exceptionCode: TwentyORMExceptionCode.FEATURE_FLAG_MAP_VERSION_NOT_FOUND,
});
}
async recomputeFeatureFlagsMapCache({
workspaceId,
ignoreLock = false,
}: {
workspaceId: string;
ignoreLock?: boolean;
}): Promise<void> {
const isAlreadyCaching =
await this.workspaceCacheStorageService.getFeatureFlagsMapOngoingCachingLock(
workspaceId,
);
if (!ignoreLock && isAlreadyCaching) {
return;
}
await this.workspaceCacheStorageService.addFeatureFlagMapOngoingCachingLock(
workspaceId,
);
const freshFeatureFlagMap =
await this.getFeatureFlagsMapFromDatabase(workspaceId);
await this.workspaceCacheStorageService.setFeatureFlagsMap(
workspaceId,
freshFeatureFlagMap,
);
await this.workspaceCacheStorageService.removeFeatureFlagsMapOngoingCachingLock(
workspaceId,
);
}
private async getFeatureFlagsMapFromDatabase(workspaceId: string) {
const workspaceFeatureFlags = await this.featureFlagRepository.find({
where: { workspaceId },
});
const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce(
(result, currentFeatureFlag) => {
result[currentFeatureFlag.key] = currentFeatureFlag.value;
return result;
},
{} as FeatureFlagMap,
);
return workspaceFeatureFlagsMap;
}
}

View File

@ -64,11 +64,11 @@ export class WorkspaceDataSource extends DataSource {
this.permissionsPerRoleId = permissionsPerRoleId;
}
setFeatureFlagMap(featureFlagMap: FeatureFlagMap) {
setFeatureFlagsMap(featureFlagMap: FeatureFlagMap) {
this.featureFlagMap = featureFlagMap;
}
setFeatureFlagMapVersion(featureFlagMapVersion: string) {
setFeatureFlagsMapVersion(featureFlagMapVersion: string) {
this.featureFlagMapVersion = featureFlagMapVersion;
}
}

View File

@ -10,7 +10,7 @@ import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interface
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service';
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
@ -21,6 +21,7 @@ import {
import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory';
import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage';
import { CacheKey } from 'src/engine/twenty-orm/storage/types/cache-key.type';
import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
type CacheResult<T, U> = {
@ -40,7 +41,7 @@ export class WorkspaceDatasourceFactory {
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly entitySchemaFactory: EntitySchemaFactory,
private readonly workspaceRolesPermissionsCacheService: WorkspaceRolesPermissionsCacheService,
private readonly workspaceFeatureFlagMapCacheService: WorkspaceFeatureFlagMapCacheService,
private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService,
) {}
public async create(
@ -55,7 +56,9 @@ export class WorkspaceDatasourceFactory {
);
const { data: cachedFeatureFlagMap, version: cachedFeatureFlagMapVersion } =
await this.getFeatureFlagMapFromCache({ workspaceId });
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMapAndVersion(
{ workspaceId },
);
const isPermissionsV2Enabled =
cachedFeatureFlagMap[FeatureFlagKey.IsPermissionsV2Enabled];
@ -201,7 +204,7 @@ export class WorkspaceDatasourceFactory {
});
}
await this.updateWorkspaceDataSourceFeatureFlagMapIfNeeded({
await this.updateWorkspaceDataSourceFeatureFlagsMapIfNeeded({
workspaceDataSource,
cachedFeatureFlagMapVersion,
cachedFeatureFlagMap,
@ -210,47 +213,6 @@ export class WorkspaceDatasourceFactory {
return workspaceDataSource;
}
private async getFromCacheWithRecompute<T, U>({
workspaceId,
getCacheData,
getCacheVersion,
recomputeCache,
cachedEntityName,
exceptionCode,
}: {
workspaceId: string;
getCacheData: (workspaceId: string) => Promise<U | undefined>;
getCacheVersion: (workspaceId: string) => Promise<T | undefined>;
recomputeCache: (params: { workspaceId: string }) => Promise<void>;
cachedEntityName: string;
exceptionCode: TwentyORMExceptionCode;
}): Promise<CacheResult<T, U>> {
let cachedVersion: T | undefined;
let cachedData: U | undefined;
cachedVersion = await getCacheVersion(workspaceId);
cachedData = await getCacheData(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
await recomputeCache({ workspaceId });
cachedData = await getCacheData(workspaceId);
cachedVersion = await getCacheVersion(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
throw new TwentyORMException(
`${cachedEntityName} not found after recompute for workspace ${workspaceId}`,
exceptionCode,
);
}
}
return {
version: cachedVersion,
data: cachedData,
};
}
private async getRolesPermissionsFromCache({
workspaceId,
isPermissionsV2Enabled,
@ -267,7 +229,7 @@ export class WorkspaceDatasourceFactory {
return { version: undefined, data: undefined };
}
return this.getFromCacheWithRecompute<
return getFromCacheWithRecompute<
string | undefined,
ObjectRecordsPermissionsByRoleId | undefined
>({
@ -287,28 +249,6 @@ export class WorkspaceDatasourceFactory {
});
}
private async getFeatureFlagMapFromCache({
workspaceId,
}: {
workspaceId: string;
}): Promise<CacheResult<string, FeatureFlagMap>> {
return this.getFromCacheWithRecompute<string, FeatureFlagMap>({
workspaceId,
getCacheData: () =>
this.workspaceCacheStorageService.getFeatureFlagMap(workspaceId),
getCacheVersion: () =>
this.workspaceCacheStorageService.getFeatureFlagMapVersionFromCache(
workspaceId,
),
recomputeCache: (params) =>
this.workspaceFeatureFlagMapCacheService.recomputeFeatureFlagMapCache(
params,
),
cachedEntityName: 'Feature flag map',
exceptionCode: TwentyORMExceptionCode.FEATURE_FLAG_MAP_VERSION_NOT_FOUND,
});
}
private updateWorkspaceDataSourceIfNeeded<T>({
workspaceDataSource,
currentVersion,
@ -355,7 +295,7 @@ export class WorkspaceDatasourceFactory {
});
}
private async updateWorkspaceDataSourceFeatureFlagMapIfNeeded({
private async updateWorkspaceDataSourceFeatureFlagsMapIfNeeded({
workspaceDataSource,
cachedFeatureFlagMapVersion,
cachedFeatureFlagMap,
@ -369,9 +309,9 @@ export class WorkspaceDatasourceFactory {
currentVersion: workspaceDataSource.featureFlagMapVersion,
newVersion: cachedFeatureFlagMapVersion,
newData: cachedFeatureFlagMap,
setData: (data) => workspaceDataSource.setFeatureFlagMap(data),
setData: (data) => workspaceDataSource.setFeatureFlagsMap(data),
setVersion: (version) =>
workspaceDataSource.setFeatureFlagMapVersion(version),
workspaceDataSource.setFeatureFlagsMapVersion(version),
});
}

View File

@ -6,7 +6,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { WorkspaceFeatureFlagMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module';
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceRolesPermissionsCacheModule } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module';
import { entitySchemaFactories } from 'src/engine/twenty-orm/factories';
@ -27,7 +27,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
WorkspaceMetadataCacheModule,
PermissionsModule,
WorkspaceRolesPermissionsCacheModule,
WorkspaceFeatureFlagMapCacheModule,
WorkspaceFeatureFlagsMapCacheModule,
FeatureFlagModule,
],
providers: [

View File

@ -0,0 +1,54 @@
import { isDefined } from 'twenty-shared/utils';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
type CacheResult<T, U> = {
version: T;
data: U;
};
const getFromCacheWithRecompute = async <T, U>({
workspaceId,
getCacheData,
getCacheVersion,
recomputeCache,
cachedEntityName,
exceptionCode,
}: {
workspaceId: string;
getCacheData: (workspaceId: string) => Promise<U | undefined>;
getCacheVersion: (workspaceId: string) => Promise<T | undefined>;
recomputeCache: (params: { workspaceId: string }) => Promise<void>;
cachedEntityName: string;
exceptionCode: TwentyORMExceptionCode;
}): Promise<CacheResult<T, U>> => {
let cachedVersion: T | undefined;
let cachedData: U | undefined;
cachedVersion = await getCacheVersion(workspaceId);
cachedData = await getCacheData(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
await recomputeCache({ workspaceId });
cachedData = await getCacheData(workspaceId);
cachedVersion = await getCacheVersion(workspaceId);
if (!isDefined(cachedData) || !isDefined(cachedVersion)) {
throw new TwentyORMException(
`${cachedEntityName} not found after recompute for workspace ${workspaceId}`,
exceptionCode,
);
}
}
return {
version: cachedVersion,
data: cachedData,
};
};
export { CacheResult, getFromCacheWithRecompute };

View File

@ -26,9 +26,9 @@ export enum WorkspaceCacheKeys {
MetadataRolesPermissions = 'metadata:roles-permissions',
MetadataRolesPermissionsVersion = 'metadata:roles-permissions-version',
MetadataRolesPermissionsOngoingCachingLock = 'metadata:roles-permissions-ongoing-caching-lock',
MetadataFeatureFlagMap = 'metadata:feature-flag-map',
MetadataFeatureFlagMapVersion = 'metadata:feature-flag-map-version',
MetadataFeatureFlagMapOngoingCachingLock = 'metadata:feature-flag-map-ongoing-caching-lock',
FeatureFlagMap = 'feature-flag-map',
FeatureFlagMapVersion = 'feature-flag-map-version',
FeatureFlagMapOngoingCachingLock = 'feature-flag-map-ongoing-caching-lock',
}
const TTL_INFINITE = 0;
@ -254,19 +254,19 @@ export class WorkspaceCacheStorageService {
);
}
getFeatureFlagMapVersionFromCache(
getFeatureFlagsMapVersionFromCache(
workspaceId: string,
): Promise<string | undefined> {
return this.cacheStorageService.get<string>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`,
);
}
async setFeatureFlagMapVersion(workspaceId: string): Promise<string> {
async setFeatureFlagsMapVersion(workspaceId: string): Promise<string> {
const featureFlagMapVersion = crypto.randomUUID();
await this.cacheStorageService.set<string>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`,
featureFlagMapVersion,
TTL_INFINITE,
);
@ -274,7 +274,7 @@ export class WorkspaceCacheStorageService {
return featureFlagMapVersion;
}
async setFeatureFlagMap(
async setFeatureFlagsMap(
workspaceId: string,
featureFlagMap: FeatureFlagMap,
): Promise<{
@ -282,41 +282,41 @@ export class WorkspaceCacheStorageService {
}> {
const [, newFeatureFlagMapVersion] = await Promise.all([
this.cacheStorageService.set<FeatureFlagMap>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
featureFlagMap,
TTL_INFINITE,
),
this.setFeatureFlagMapVersion(workspaceId),
this.setFeatureFlagsMapVersion(workspaceId),
]);
return { newFeatureFlagMapVersion };
}
getFeatureFlagMap(workspaceId: string): Promise<FeatureFlagMap | undefined> {
getFeatureFlagsMap(workspaceId: string): Promise<FeatureFlagMap | undefined> {
return this.cacheStorageService.get<FeatureFlagMap>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
);
}
addFeatureFlagMapOngoingCachingLock(workspaceId: string) {
return this.cacheStorageService.set<boolean>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
true,
1_000 * 60, // 1 minute
);
}
removeFeatureFlagMapOngoingCachingLock(workspaceId: string) {
removeFeatureFlagsMapOngoingCachingLock(workspaceId: string) {
return this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
);
}
getFeatureFlagMapOngoingCachingLock(
getFeatureFlagsMapOngoingCachingLock(
workspaceId: string,
): Promise<boolean | undefined> {
return this.cacheStorageService.get<boolean>(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
);
}
@ -353,15 +353,15 @@ export class WorkspaceCacheStorageService {
);
await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`,
);
await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`,
);
await this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`,
`${WorkspaceCacheKeys.FeatureFlagMapOngoingCachingLock}:${workspaceId}`,
);
// TODO: remove this after the feature flag is droped

View File

@ -456,7 +456,6 @@ describe('workspace permissions', () => {
$input: UpdateLabPublicFeatureFlagInput!
) {
updateLabPublicFeatureFlag(input: $input) {
id
key
value
}
@ -490,7 +489,6 @@ describe('workspace permissions', () => {
$input: UpdateLabPublicFeatureFlagInput!
) {
updateLabPublicFeatureFlag(input: $input) {
id
key
value
}