From 3a1067ec6db664737e3a10d5b3643b9c73b9a08b Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Fri, 24 Jul 2026 18:50:57 +0200
Subject: [PATCH] fix(server): index page-layout FKs to fix workspace cleanup
timeout (#23289)
The cleanSuspendedWorkspacesJob cron timed out every run (Sentry monitor
"a timeout check-in was detected"): hard-deleting soft-deleted
workspaces hung on `DELETE FROM core.pageLayout`, hit the 10s query
timeout, rolled back, so those workspaces were never destroyed and got
retried hourly.
Root cause: the FKs in the pageLayout -> pageLayoutTab ->
pageLayoutWidget tree had no usable index on the referencing column. The
existing indexes lead with workspaceId and are partial ("deletedAt" IS
NULL), so ON DELETE CASCADE / SET NULL fell back to full sequential
scans of the shared core tables per deleted row; on layout-heavy
workspaces this exceeded 10s.
- Add non-partial FK-column indexes on pageLayoutTab(pageLayoutId) and
pageLayoutWidget(pageLayoutTabId)
---
...-add-page-layout-cascade-delete-indexes.ts | 30 +++++++++++++++++++
.../instance-commands.constant.ts | 2 ++
.../entities/page-layout-tab.entity.ts | 3 +-
.../entities/page-layout-widget.entity.ts | 3 +-
4 files changed, 36 insertions(+), 2 deletions(-)
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1784904030251-add-page-layout-cascade-delete-indexes.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1784904030251-add-page-layout-cascade-delete-indexes.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1784904030251-add-page-layout-cascade-delete-indexes.ts
new file mode 100644
index 0000000000..94609abe98
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1784904030251-add-page-layout-cascade-delete-indexes.ts
@@ -0,0 +1,30 @@
+import { type QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
+import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
+
+const PAGE_LAYOUT_TAB_PAGE_LAYOUT_ID_INDEX_NAME =
+ 'IDX_PAGE_LAYOUT_TAB_PAGE_LAYOUT_ID';
+const PAGE_LAYOUT_WIDGET_PAGE_LAYOUT_TAB_ID_INDEX_NAME =
+ 'IDX_PAGE_LAYOUT_WIDGET_PAGE_LAYOUT_TAB_ID';
+
+@RegisteredInstanceCommand('2.25.0', 1784904030251)
+export class AddPageLayoutCascadeDeleteIndexesFastInstanceCommand implements FastInstanceCommand {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "${PAGE_LAYOUT_TAB_PAGE_LAYOUT_ID_INDEX_NAME}" ON "core"."pageLayoutTab" ("pageLayoutId")`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "${PAGE_LAYOUT_WIDGET_PAGE_LAYOUT_TAB_ID_INDEX_NAME}" ON "core"."pageLayoutWidget" ("pageLayoutTabId")`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS "core"."${PAGE_LAYOUT_WIDGET_PAGE_LAYOUT_TAB_ID_INDEX_NAME}"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS "core"."${PAGE_LAYOUT_TAB_PAGE_LAYOUT_ID_INDEX_NAME}"`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
index 1fb5efbba7..c3accf9ae5 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
@@ -124,6 +124,7 @@ import { AddStatusesToBillingSubscriptionIndexSlowInstanceCommand } from './2-23
import { AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand } from './2-24/2-24-instance-command-fast-1784712843602-add-on-connect-logic-function-to-connection-provider';
import { RepairKeyValuePairApplicationIdFastInstanceCommand } from './2-24/2-24-instance-command-fast-1784897347051-repair-key-value-pair-application-id';
import { AddAgentForeignKeyToRoleTargetFastInstanceCommand } from './2-25/2-25-instance-command-fast-1784820332810-add-agent-foreign-key-to-role-target';
+import { AddPageLayoutCascadeDeleteIndexesFastInstanceCommand } from './2-25/2-25-instance-command-fast-1784904030251-add-page-layout-cascade-delete-indexes';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -250,4 +251,5 @@ export const INSTANCE_COMMANDS = [
AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand,
RepairKeyValuePairApplicationIdFastInstanceCommand,
AddAgentForeignKeyToRoleTargetFastInstanceCommand,
+ AddPageLayoutCascadeDeleteIndexesFastInstanceCommand,
];
diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity.ts
index 3eac30f400..7f8d08757a 100644
--- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity.ts
@@ -15,8 +15,8 @@ import {
UpdateDateColumn,
} from 'typeorm';
-import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { ADD_IS_SYSTEM_SIDE_EFFECT_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-15/is-system-side-effect-upgrade-command-name.constant';
+import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { OverridableEntity } from 'src/engine/workspace-manager/types/overridable-entity';
@@ -34,6 +34,7 @@ export type PageLayoutTabOverrides = {
['workspaceId', 'pageLayoutId'],
{ where: '"deletedAt" IS NULL' },
)
+@Index('IDX_PAGE_LAYOUT_TAB_PAGE_LAYOUT_ID', ['pageLayoutId'])
export class PageLayoutTabEntity
extends OverridableEntity
implements Required
diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity.ts
index d05021c363..e18f5e77e1 100644
--- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity.ts
@@ -19,8 +19,8 @@ import {
type Relation,
} from 'typeorm';
-import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { ADD_IS_SYSTEM_SIDE_EFFECT_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-15/is-system-side-effect-upgrade-command-name.constant';
+import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@@ -45,6 +45,7 @@ export type PageLayoutWidgetOverrides = {
{ where: '"deletedAt" IS NULL' },
)
@Index('IDX_PAGE_LAYOUT_WIDGET_OBJECT_METADATA_ID', ['objectMetadataId'])
+@Index('IDX_PAGE_LAYOUT_WIDGET_PAGE_LAYOUT_TAB_ID', ['pageLayoutTabId'])
export class PageLayoutWidgetEntity<
TWidgetConfigurationType extends WidgetConfigurationType =
WidgetConfigurationType,