Clean soft deleted suspended workspace command and ignore-grace-period flag (#17144)

# Introduction

In the https://github.com/twentyhq/core-team-issues/issues/1989 's
context we will apply migration through an upgrade command post entity
backfill to fit the applied constraint. But suspended soft deleted
workspace are not included in the upgrade workspace batches

In order to be able to pass the constraint in production, discussed with
@FelixMalfait, we will manually clear all the currently suspended and
soft deleted workspace ignore their grace period

## Force mode
When running the command in force it will ignore the limit per execution
( which really serve the cron job ) and the grace period

## Test
Tested on a production extract
This commit is contained in:
Paul Rastoin
2026-01-14 15:15:15 +01:00
committed by GitHub
parent 2c24180b44
commit 9e4247c934
11 changed files with 204 additions and 95 deletions
@@ -26,6 +26,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
@@ -60,6 +61,12 @@ describe('WorkspaceService', () => {
findOneBy: jest.fn(),
},
},
{
provide: ObjectMetadataService,
useValue: {
deleteWorkspaceAllObjectMetadata: jest.fn(),
},
},
{
provide: getRepositoryToken(UserWorkspaceEntity),
useValue: {
@@ -280,16 +287,10 @@ describe('WorkspaceService', () => {
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace);
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([]);
jest
.spyOn(service, 'deleteMetadataSchemaCacheAndUserWorkspace')
.mockResolvedValue({} as WorkspaceEntity);
await service.deleteWorkspace(mockWorkspace.id, false);
expect(workspaceRepository.delete).toHaveBeenCalledWith(mockWorkspace.id);
expect(
service.deleteMetadataSchemaCacheAndUserWorkspace,
).toHaveBeenCalled();
expect(workspaceRepository.softDelete).not.toHaveBeenCalled();
expect(workspaceCacheStorageService.flush).toHaveBeenCalledWith(
mockWorkspace.id,
@@ -331,9 +332,6 @@ describe('WorkspaceService', () => {
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace);
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([]);
jest
.spyOn(service, 'deleteMetadataSchemaCacheAndUserWorkspace')
.mockResolvedValue({} as WorkspaceEntity);
await service.deleteWorkspace(mockWorkspace.id, false);
@@ -37,6 +37,7 @@ import {
WorkspaceNotFoundDefaultError,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import {
PermissionsException,
PermissionsExceptionCode,
@@ -44,8 +45,8 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-companies';
import { prefillDashboards } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-dashboards';
import { prefillOpportunities } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-opportunities';
@@ -102,6 +103,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly subdomainManagerService: SubdomainManagerService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly customDomainManagerService: CustomDomainManagerService,
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
@@ -282,20 +284,6 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
});
}
/**
* @deprecated Should be removed once AddWorkspaceForeignKeysMigrationCommand has been run successfully in production
* As we will be able to rely on foreignKey delete cascading
*/
async deleteMetadataSchemaCacheAndUserWorkspace(workspace: WorkspaceEntity) {
await this.userWorkspaceService.deleteUserWorkspace({
userWorkspaceId: workspace.id,
});
await this.workspaceManagerService.delete(workspace.id);
return workspace;
}
async deleteWorkspace(id: string, softDelete = false) {
const workspace = await this.workspaceRepository.findOne({
where: { id },
@@ -320,13 +308,6 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
}
this.logger.log(`workspace ${id} user workspaces deleted`);
await this.workspaceCacheStorageService.flush(
workspace.id,
workspace.metadataVersion,
);
await this.flatEntityMapsCacheService.flushFlatEntityMaps({
workspaceId: workspace.id,
});
this.logger.log(`workspace ${id} cache flushed`);
if (this.billingService.isBillingEnabled()) {
@@ -341,10 +322,21 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
return workspace;
}
await this.deleteMetadataSchemaCacheAndUserWorkspace(workspace);
// Note: not relying on workspace id FK cascade deletion here to avoid query read timeout later on workspace deletion
await this.objectMetadataService.deleteWorkspaceAllObjectMetadata({
workspaceId: workspace.id,
});
await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspace.id);
await this.workspaceCacheStorageService.flush(
workspace.id,
workspace.metadataVersion,
);
await this.flatEntityMapsCacheService.flushFlatEntityMaps({
workspaceId: workspace.id,
});
await this.messageQueueService.add<FileWorkspaceFolderDeletionJobData>(
FileWorkspaceFolderDeletionJob.name,
{ workspaceId: id },
@@ -31,6 +31,7 @@ import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.r
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
@@ -55,6 +56,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
UserWorkspaceEntity,
PublicDomainEntity,
]),
ObjectMetadataModule,
UserWorkspaceModule,
WorkspaceManagerModule,
FeatureFlagModule,