From a47e1e0e5ecdf01e365297aa6b6824a2e0fa4b96 Mon Sep 17 00:00:00 2001 From: Weiko Date: Thu, 14 May 2026 12:00:33 +0200 Subject: [PATCH] Fix time consuming search ilike fallback (#20544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context When the tsvector full-text search returns 0 hits on the first page, SearchService falls back to ILIKE '%word%' over searchVector::text. The leading wildcard makes the GIN index unusable, so it seq-scans the table. On large searchable custom objects (e.g. a workspace with ~500k rows in _logs) a single fallback can take 2–3s, multiplied across all searchable objects in one request. ## Implementation Wrap the fallback query in a tiny TypeORM transaction and apply a Postgres per-statement timeout via set_config('statement_timeout', ms, true) (= SET LOCAL). On timeout, Postgres throws 57014 (QUERY_CANCELED); we catch it, warn-log with workspace/object context, and return [] for that object ## Note This PR bounds the slow fallback and doesn't make it fast. The right structural fix is to let the fallback use an index. Since tsvector does not work with certain language (which is the reason why the ILIKE fallback was implemented in the first place), we should probably use the pg_trgm extension instead (@FelixMalfait) --------- Co-authored-by: Charles Bochet --- .../utils/is-query-canceled-error.util.ts | 11 ++ .../search/__tests__/search.service.spec.ts | 8 +- .../search/services/search.service.ts | 140 ++++++++++++------ .../twenty-config/config-variables.ts | 11 ++ 4 files changed, 122 insertions(+), 48 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/is-query-canceled-error.util.ts diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/is-query-canceled-error.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/is-query-canceled-error.util.ts new file mode 100644 index 0000000000..ae1c0df9c3 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/is-query-canceled-error.util.ts @@ -0,0 +1,11 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants'; + +export const isQueryCanceledError = (error: unknown): boolean => { + if (!isDefined(error) || typeof error !== 'object' || !('code' in error)) { + return false; + } + + return error.code === POSTGRESQL_ERROR_CODES.QUERY_CANCELED; +}; diff --git a/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts b/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts index ef47e8ac78..191a51242d 100644 --- a/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts @@ -19,7 +19,13 @@ describe('SearchService', () => { SearchService, { provide: GlobalWorkspaceOrmManager, useValue: {} }, { provide: FileUrlService, useValue: {} }, - { provide: TwentyConfigService, useValue: { get: () => false } }, + { + provide: TwentyConfigService, + useValue: { + get: (key: string) => + key === 'SEARCH_ILIKE_FALLBACK_TIMEOUT_MS' ? 500 : false, + }, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts index 7c919ec824..81f255bb16 100644 --- a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts +++ b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { isNonEmptyString } from '@sniptt/guards'; import chunk from 'lodash.chunk'; @@ -19,6 +19,7 @@ import { decodeCursor, encodeCursorData, } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; +import { isQueryCanceledError } from 'src/engine/api/graphql/workspace-query-runner/utils/is-query-canceled-error.util'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util'; import { STANDARD_OBJECTS_BY_PRIORITY_RANK } from 'src/engine/core-modules/search/constants/standard-objects-by-priority-rank'; @@ -56,6 +57,8 @@ const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5; @Injectable() export class SearchService { + private readonly logger = new Logger(SearchService.name); + constructor( private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, private readonly fileUrlService: FileUrlService, @@ -356,64 +359,107 @@ export class SearchService { limit: number; filter: ObjectRecordFilterInput; }) { - const queryBuilder = entityManager.createQueryBuilder(); - - const { flatObjectMetadataMaps } = entityManager.internalContext; - - const queryParser = new GraphqlQueryParser( - flatObjectMetadata, - flatObjectMetadataMaps, - flatFieldMetadataMaps, + const timeoutMs = this.twentyConfigService.get( + 'SEARCH_ILIKE_FALLBACK_TIMEOUT_MS', ); - queryParser.applyFilterToBuilder( - queryBuilder, - flatObjectMetadata.nameSingular, - filter, - ); + // Must not run inside a caller transaction: SET LOCAL is transaction-scoped + // and would leak into the outer transaction. + try { + return await entityManager.manager.transaction( + async (transactionManager) => { + const { queryRunner } = transactionManager; - queryParser.applyDeletedAtToBuilder(queryBuilder, filter); + if (!isDefined(queryRunner)) { + throw new Error( + 'Expected queryRunner to be defined within transaction', + ); + } - const imageIdentifierField = this.getImageIdentifierColumn( - flatObjectMetadata, - flatFieldMetadataMaps, - ); + await queryRunner.query( + `SELECT set_config('statement_timeout', $1, true)`, + [String(timeoutMs)], + ); - const fieldsToSelect = [ - 'id', - ...this.getLabelIdentifierColumns( - flatObjectMetadata, - flatFieldMetadataMaps, - ), - ...(imageIdentifierField ? [imageIdentifierField] : []), - ].map((field) => `"${field}"`); + const queryBuilder = entityManager.createQueryBuilder( + undefined, + queryRunner, + ); - queryBuilder.select(fieldsToSelect); + const { flatObjectMetadataMaps } = entityManager.internalContext; - const searchWords = searchInput - .trim() - .split(/\s+/) - .filter(isNonEmptyString); + const queryParser = new GraphqlQueryParser( + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + ); - searchWords.forEach((word, index) => { - const paramName = `ilikeFallback${index}`; + queryParser.applyFilterToBuilder( + queryBuilder, + flatObjectMetadata.nameSingular, + filter, + ); - queryBuilder.andWhere( - `public.unaccent_immutable("${SEARCH_VECTOR_FIELD.name}"::text) ILIKE public.unaccent_immutable(:${paramName})`, - { [paramName]: `%${escapeForIlike(word)}%` }, + queryParser.applyDeletedAtToBuilder(queryBuilder, filter); + + const imageIdentifierField = this.getImageIdentifierColumn( + flatObjectMetadata, + flatFieldMetadataMaps, + ); + + const fieldsToSelect = [ + 'id', + ...this.getLabelIdentifierColumns( + flatObjectMetadata, + flatFieldMetadataMaps, + ), + ...(imageIdentifierField ? [imageIdentifierField] : []), + ].map((field) => `"${field}"`); + + queryBuilder.select(fieldsToSelect); + + const searchWords = searchInput + .trim() + .split(/\s+/) + .filter(isNonEmptyString); + + searchWords.forEach((word, index) => { + const paramName = `ilikeFallback${index}`; + + queryBuilder.andWhere( + `public.unaccent_immutable("${SEARCH_VECTOR_FIELD.name}"::text) ILIKE public.unaccent_immutable(:${paramName})`, + { [paramName]: `%${escapeForIlike(word)}%` }, + ); + }); + + const rawResults = await queryBuilder + .orderBy('"id"', 'ASC') + .take(limit) + .getRawMany(); + + return rawResults.map((record) => ({ + ...record, + tsRankCD: 0, + tsRank: 0, + })); + }, ); - }); + } catch (error) { + if (isQueryCanceledError(error)) { + this.logger.warn( + `Search ILIKE fallback exceeded ${timeoutMs}ms timeout`, + { + workspaceId: entityManager.internalContext.workspaceId, + objectNameSingular: flatObjectMetadata.nameSingular, + searchInputLength: searchInput.length, + }, + ); - const rawResults = await queryBuilder - .orderBy('"id"', 'ASC') - .take(limit) - .getRawMany(); + return []; + } - return rawResults.map((record) => ({ - ...record, - tsRankCD: 0, - tsRank: 0, - })); + throw error; + } } computeCursorWhereCondition({ diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 990171fb90..1023bad75a 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1704,6 +1704,17 @@ export class ConfigVariables { @IsOptional() PG_DATABASE_REPLICA_TIMEOUT_MS: number = 10000; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.ADVANCED_SETTINGS, + description: + 'Timeout in milliseconds for the search ILIKE fallback query per searchable object. Triggered only when the tsvector query returns 0 results on the first page (e.g. CJK input). When the timeout fires the fallback is skipped for that object.', + type: ConfigVariableType.NUMBER, + isEnvOnly: true, + }) + @CastToPositiveNumber() + @IsOptional() + SEARCH_ILIKE_FALLBACK_TIMEOUT_MS: number = 2000; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.ADVANCED_SETTINGS, description: