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: