Fix time consuming search ilike fallback (#20544)

## 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 <charles@twenty.com>
This commit is contained in:
Weiko
2026-05-14 12:00:33 +02:00
committed by GitHub
parent 0d5617d446
commit a47e1e0e5e
4 changed files with 122 additions and 48 deletions
@@ -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;
};
@@ -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();
@@ -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({
@@ -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: