From 83c40bb8ccfe2d715556f17490e454435b542c6d Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 6 May 2026 17:41:46 +0200 Subject: [PATCH] fix(server): bypass workspace cache in onboardingStatus resolver (#20322) ## Summary In multi-instance deployments, `coreEntityCacheService` memoizes the workspace entity per server for ~10s, bypassing Redis hash invalidation. After `activateWorkspace`, if the next `currentUser` query is routed to a stale replica, the server returns `onboardingStatus: WORKSPACE_ACTIVATION` and `workspaceMember: null`, the client redirects to `/create/profile`, and submitting the form throws "User is not logged in". Reproduces on prod/staging only (local dev = single instance). Fix: in `OnboardingService.getOnboardingStatus({ user, workspaceId })`, read the workspace directly from `WorkspaceEntity` repository (bypassing the per-instance core entity cache) so `onboardingStatus` reflects the freshest `activationStatus` right after `activateWorkspace`, even when the request hits a replica with a stale cached workspace. ## Test plan - Prod/staging: sign up + create workspace, verify `/create/profile` works and form submits. - Local: regression on the full onboarding flow. --- .../onboarding/onboarding.module.ts | 9 ++++++- .../onboarding/onboarding.service.ts | 25 +++++++++++++++++-- .../engine/core-modules/user/user.resolver.ts | 5 +++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts index b5daa947c0..c7a98ec374 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts @@ -1,13 +1,20 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { OnboardingResolver } from 'src/engine/core-modules/onboarding/onboarding.resolver'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @Module({ - imports: [BillingModule, UserVarsModule, FeatureFlagModule], + imports: [ + BillingModule, + UserVarsModule, + FeatureFlagModule, + TypeOrmModule.forFeature([WorkspaceEntity]), + ], exports: [OnboardingService], providers: [OnboardingService, OnboardingResolver], }) diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts index e926ee5b3b..6bb3bbfb66 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { type QueryRunner } from 'typeorm'; +import { type QueryRunner, Repository } from 'typeorm'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; @@ -32,6 +33,8 @@ export class OnboardingService { private readonly billingService: BillingService, private readonly userVarsService: UserVarsService, private readonly twentyConfigService: TwentyConfigService, + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, ) {} private isWorkspaceActivationPending(workspace: WorkspaceEntity) { @@ -40,7 +43,25 @@ export class OnboardingService { ); } - async getOnboardingStatus(user: UserEntity, workspace: WorkspaceEntity) { + async getOnboardingStatus({ + user, + workspaceId, + }: { + user: UserEntity; + workspaceId: string; + }): Promise { + // We always read the workspace directly from the database here (bypassing + // the per-instance core entity cache) so that onboardingStatus reflects the + // freshest activationStatus right after activateWorkspace, even when a + // sibling server instance still has a stale cached workspace. + const workspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + }); + + if (!isDefined(workspace)) { + return null; + } + if ( await this.billingService.isSubscriptionIncompleteOnboardingStatus( workspace.id, diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index 6739394079..416546fe03 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -557,7 +557,10 @@ export class UserResolver { ): Promise { if (!workspace) return null; - return this.onboardingService.getOnboardingStatus(user, workspace); + return this.onboardingService.getOnboardingStatus({ + user, + workspaceId: workspace.id, + }); } @ResolveField(() => WorkspaceEntity, {