From d4deca45e894ba7f3efd2910191121f4c826bdfd Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Mon, 14 Apr 2025 17:31:13 +0200 Subject: [PATCH] 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. --- .../twenty-front/src/generated/graphql.tsx | 19 ++-- .../mutations/updateLabPublicFeatureFlag.ts | 1 - .../lab/hooks/useLabPublicFeatureFlags.ts | 3 +- .../graphql/fragments/userQueryFragment.ts | 2 - .../src/testing/mock-data/users.ts | 6 -- .../feature-flag/dtos/feature-flag-dto.ts | 16 +++ .../feature-flag/feature-flag.module.ts | 2 + .../__tests__/feature-flag.service.spec.ts | 49 ++++++--- .../services/feature-flag.service.ts | 70 +++++++----- .../src/engine/core-modules/lab/lab.module.ts | 7 +- .../engine/core-modules/lab/lab.resolver.ts | 18 +--- .../workspace/workspace.resolver.ts | 8 +- ...orkspace-feature-flag-map-cache.service.ts | 47 -------- ...ace-roles-feature-flag-map-cache.module.ts | 18 ---- ...orkspace-feature-flags-map-cache.module.ts | 17 +++ ...rkspace-feature-flags-map-cache.service.ts | 102 ++++++++++++++++++ .../datasource/workspace.datasource.ts | 4 +- .../factories/workspace-datasource.factory.ts | 82 ++------------ .../engine/twenty-orm/twenty-orm.module.ts | 4 +- ...get-data-from-cache-with-recompute.util.ts | 54 ++++++++++ .../workspace-cache-storage.service.ts | 40 +++---- .../workspace.integration-spec.ts | 2 - 22 files changed, 323 insertions(+), 248 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/feature-flag/dtos/feature-flag-dto.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/utils/get-data-from-cache-with-recompute.util.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 3379f8e18..8aacc9bfe 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -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; deletedAt?: Maybe; displayName?: Maybe; - featureFlags?: Maybe>; + featureFlags?: Maybe>; hasValidEnterpriseKey: Scalars['Boolean']; id: Scalars['UUID']; inviteHash?: Maybe; @@ -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 | null, objectRecordsPermissions?: Array | 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 | null, objectRecordsPermissions?: Array | 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 | null, objectRecordsPermissions?: Array | 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 | null, objectRecordsPermissions?: Array | 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 { const [error, setError] = useState(null); @@ -28,7 +28,6 @@ export const useLabPublicFeatureFlags = () => { ) ?? []), { ...updatedFlag, - workspaceId: currentWorkspace.id, }, ], }); diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index e836d4b6d..bf9ba8655 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -43,10 +43,8 @@ export const USER_QUERY_FRAGMENT = gql` customUrl } featureFlags { - id key value - workspaceId } metadataVersion currentBillingSubscription { diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 8d5143448..4e1484413 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/dtos/feature-flag-dto.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/dtos/feature-flag-dto.ts new file mode 100644 index 000000000..24ecc2505 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/dtos/feature-flag-dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts index f3500a726..d4418059a 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts @@ -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], diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/services/__tests__/feature-flag.service.spec.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/services/__tests__/feature-flag.service.spec.ts index 21475475e..4737560cc 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/services/__tests__/feature-flag.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/services/__tests__/feature-flag.service.spec.ts @@ -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 }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/services/feature-flag.service.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/services/feature-flag.service.ts index dfdbd48f5..327e8a5af 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/services/feature-flag.service.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/services/feature-flag.service.ts @@ -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, + private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService, ) {} public async isFeatureEnabled( key: FeatureFlagKey, workspaceId: string, ): Promise { - 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 { - return this.featureFlagRepository.find({ where: { workspaceId } }); + ): Promise { + 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 { - 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 { - 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; } } diff --git a/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts b/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts index 1153689f8..67dea4a5e 100644 --- a/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts +++ b/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts @@ -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: [], }) diff --git a/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts b/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts index a6a0c39fb..6ffcbab2d 100644 --- a/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts @@ -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 { + ): Promise { 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) { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 3aa5c9c7f..99b1d81ec 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -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 { + @ResolveField(() => [FeatureFlagDTO], { nullable: true }) + async featureFlags( + @Parent() workspace: Workspace, + ): Promise { const featureFlags = await this.featureFlagService.getWorkspaceFeatureFlags( workspace.id, ); diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service.ts deleted file mode 100644 index 6e8cf473d..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service.ts +++ /dev/null @@ -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 { - 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, - ); - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module.ts deleted file mode 100644 index f44911b07..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module.ts +++ /dev/null @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module.ts new file mode 100644 index 000000000..c0a5470c7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service.ts new file mode 100644 index 000000000..100699da3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service.ts @@ -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, + ) {} + + async getWorkspaceFeatureFlagsMap({ + workspaceId, + }: { + workspaceId: string; + }): Promise { + const { data: workspaceFeatureFlagsMap } = + await this.getWorkspaceFeatureFlagsMapAndVersion({ workspaceId }); + + return workspaceFeatureFlagsMap; + } + + async getWorkspaceFeatureFlagsMapAndVersion({ + workspaceId, + }: { + workspaceId: string; + }) { + return getFromCacheWithRecompute({ + 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 { + 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; + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/datasource/workspace.datasource.ts b/packages/twenty-server/src/engine/twenty-orm/datasource/workspace.datasource.ts index 44385334a..e75f49a64 100644 --- a/packages/twenty-server/src/engine/twenty-orm/datasource/workspace.datasource.ts +++ b/packages/twenty-server/src/engine/twenty-orm/datasource/workspace.datasource.ts @@ -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; } } diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts index 44a82a6c2..5d3a2ad8f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts +++ b/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts @@ -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 = { @@ -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({ - workspaceId, - getCacheData, - getCacheVersion, - recomputeCache, - cachedEntityName, - exceptionCode, - }: { - workspaceId: string; - getCacheData: (workspaceId: string) => Promise; - getCacheVersion: (workspaceId: string) => Promise; - recomputeCache: (params: { workspaceId: string }) => Promise; - cachedEntityName: string; - exceptionCode: TwentyORMExceptionCode; - }): Promise> { - 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> { - return this.getFromCacheWithRecompute({ - 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({ 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), }); } diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts index 53043aa00..d936a7c91 100644 --- a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts @@ -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: [ diff --git a/packages/twenty-server/src/engine/utils/get-data-from-cache-with-recompute.util.ts b/packages/twenty-server/src/engine/utils/get-data-from-cache-with-recompute.util.ts new file mode 100644 index 000000000..c46762b31 --- /dev/null +++ b/packages/twenty-server/src/engine/utils/get-data-from-cache-with-recompute.util.ts @@ -0,0 +1,54 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; + +type CacheResult = { + version: T; + data: U; +}; + +const getFromCacheWithRecompute = async ({ + workspaceId, + getCacheData, + getCacheVersion, + recomputeCache, + cachedEntityName, + exceptionCode, +}: { + workspaceId: string; + getCacheData: (workspaceId: string) => Promise; + getCacheVersion: (workspaceId: string) => Promise; + recomputeCache: (params: { workspaceId: string }) => Promise; + cachedEntityName: string; + exceptionCode: TwentyORMExceptionCode; +}): Promise> => { + 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 }; diff --git a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts index 40c7c9d7b..c8a53a837 100644 --- a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts +++ b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts @@ -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 { return this.cacheStorageService.get( - `${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`, + `${WorkspaceCacheKeys.FeatureFlagMapVersion}:${workspaceId}`, ); } - async setFeatureFlagMapVersion(workspaceId: string): Promise { + async setFeatureFlagsMapVersion(workspaceId: string): Promise { const featureFlagMapVersion = crypto.randomUUID(); await this.cacheStorageService.set( - `${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( - `${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`, + `${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`, featureFlagMap, TTL_INFINITE, ), - this.setFeatureFlagMapVersion(workspaceId), + this.setFeatureFlagsMapVersion(workspaceId), ]); return { newFeatureFlagMapVersion }; } - getFeatureFlagMap(workspaceId: string): Promise { + getFeatureFlagsMap(workspaceId: string): Promise { return this.cacheStorageService.get( - `${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`, + `${WorkspaceCacheKeys.FeatureFlagMap}:${workspaceId}`, ); } addFeatureFlagMapOngoingCachingLock(workspaceId: string) { return this.cacheStorageService.set( - `${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 { return this.cacheStorageService.get( - `${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 diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts index 372c07453..6641465fd 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts @@ -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 }