From 6faf64022fe662443d4af6847bd11b4dfccb3f7c Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 14 Jul 2026 14:12:37 +0300 Subject: [PATCH 1/2] feat(search): expose timeout warnings on FT.SEARCH, FT.AGGREGATE, FT.HYBRID Surface the server `warning` field returned alongside partial results when a query hits its timeout under a `return`/`return-strict` on-timeout policy. - Add shared `parseWarnings` helper in reply-transformers that extracts the `warning` field from a RESP3 map reply and normalizes it to `string[]`. - Add `warnings: Array` to SearchReply, SearchNoContentReply, and AggregateReply; FT.HYBRID already exposed warnings and now reuses the helper. FT.SEARCH and FT.AGGREGATE only carry warnings on RESP3 (empty array on RESP2); FT.HYBRID reports on both protocols. The new field is additive. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../search/lib/commands/AGGREGATE.spec.ts | 37 ++++++++++++++++- packages/search/lib/commands/AGGREGATE.ts | 15 +++++-- .../lib/commands/AGGREGATE_WITHCURSOR.spec.ts | 1 + packages/search/lib/commands/HYBRID.ts | 19 +-------- packages/search/lib/commands/SEARCH.spec.ts | 40 ++++++++++++++++++- packages/search/lib/commands/SEARCH.ts | 15 +++++-- .../lib/commands/SEARCH_NOCONTENT.spec.ts | 3 +- .../search/lib/commands/SEARCH_NOCONTENT.ts | 14 ++++++- .../search/lib/commands/reply-transformers.ts | 24 +++++++++++ 9 files changed, 139 insertions(+), 29 deletions(-) diff --git a/packages/search/lib/commands/AGGREGATE.spec.ts b/packages/search/lib/commands/AGGREGATE.spec.ts index 210cd86c27c..0afcac7d563 100644 --- a/packages/search/lib/commands/AGGREGATE.spec.ts +++ b/packages/search/lib/commands/AGGREGATE.spec.ts @@ -641,6 +641,40 @@ describe('AGGREGATE', () => { }); }); + describe('transformReply', () => { + it('RESP2 reply has empty warnings', () => { + assert.deepEqual( + AGGREGATE.transformReply[2]([0]), + { total: 0, results: [], warnings: [] } + ); + }); + + it('RESP3 reply populates warnings from the `warning` field', () => { + const reply = new Map([ + ['total_results', 0], + ['results', []], + ['warning', ['Timeout limit was reached']] + ]); + + assert.deepEqual( + AGGREGATE.transformReply[3](reply), + { total: 0, results: [], warnings: ['Timeout limit was reached'] } + ); + }); + + it('RESP3 reply without a `warning` field yields empty warnings', () => { + const reply = new Map([ + ['total_results', 0], + ['results', []] + ]); + + assert.deepEqual( + AGGREGATE.transformReply[3](reply), + { total: 0, results: [], warnings: [] } + ); + }); + }); + testUtils.testWithClient('client.ft.aggregate', async client => { await client.ft.create('index', { field: 'NUMERIC' @@ -678,7 +712,8 @@ describe('AGGREGATE', () => { enumerable: true } }) - ] + ], + warnings: [] } ); }, GLOBAL.SERVERS.OPEN); diff --git a/packages/search/lib/commands/AGGREGATE.ts b/packages/search/lib/commands/AGGREGATE.ts index 84629328b24..f7d90fdabcb 100644 --- a/packages/search/lib/commands/AGGREGATE.ts +++ b/packages/search/lib/commands/AGGREGATE.ts @@ -4,7 +4,7 @@ import { RediSearchProperty } from './CREATE'; import { FtSearchParams, parseParamsArgument } from './SEARCH'; import { transformTuplesReply } from '@redis/client/dist/lib/commands/generic-transformers'; import { DEFAULT_DIALECT } from '../dialect/default'; -import { getMapValue, mapLikeToFlatArray, mapLikeToObject, mapLikeValues, parseAggregateResultRow } from './reply-transformers'; +import { getMapValue, mapLikeToFlatArray, mapLikeToObject, mapLikeValues, parseAggregateResultRow, parseWarnings } from './reply-transformers'; import { RESP_TYPES } from '@redis/client/dist/lib/RESP/decoder'; type LoadField = RediSearchProperty | { @@ -149,6 +149,12 @@ export type AggregateRawReply = [ export interface AggregateReply { total: number; results: Array>; + /** + * Warnings returned alongside partial results (e.g. on query timeout under a + * `return` / `return-strict` on-timeout policy). Only populated on RESP3; + * always empty on RESP2. + */ + warnings: Array; }; function transformAggregateReplyResp2( @@ -169,7 +175,9 @@ function transformAggregateReplyResp2( // FT.AGGREGATE returns an array reply where each row is an array reply and represents a single aggregate result. // The integer reply at position 1 does not represent a valid value. total: Number(rawReply[0]), - results + results, + // FT.AGGREGATE only emits warnings on RESP3; RESP2 replies never carry them. + warnings: [] }; } @@ -206,7 +214,8 @@ function transformAggregateReplyResp3( return { total, - results + results, + warnings: parseWarnings(reply) }; } diff --git a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.spec.ts b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.spec.ts index 80718b3d845..0aa58085dfc 100644 --- a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.spec.ts +++ b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.spec.ts @@ -42,6 +42,7 @@ describe('AGGREGATE WITHCURSOR', () => { { total: 0, results: [], + warnings: [], cursor: 0 } ); diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 6bef1dfaa4d..28ccca466b7 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -15,6 +15,7 @@ import { mapLikeToObject, mapLikeValues, parseDocumentValue, + parseWarnings, } from "./reply-transformers"; /** @@ -443,9 +444,6 @@ function transformHybridSearchResults(reply: unknown): HybridSearchResult { ); const rawResults = mapLikeValues(getMapValue(replyMap, ["results"]) ?? []); - const warnings = mapLikeValues( - getMapValue(replyMap, ["warnings", "warning"]) ?? [], - ); const executionTimeValue = getMapValue(replyMap, [ "execution_time", @@ -493,7 +491,7 @@ function transformHybridSearchResults(reply: unknown): HybridSearchResult { return { totalResults, executionTime, - warnings: warnings.map(toWarningString), + warnings: parseWarnings(replyMap), results, }; } @@ -501,16 +499,3 @@ function transformHybridSearchResults(reply: unknown): HybridSearchResult { function parseReplyMap(reply: unknown): Record { return mapLikeToObject(reply); } - -function toWarningString(warning: unknown): string { - if (typeof warning === 'string') return warning; - if (warning instanceof Buffer) return warning.toString(); - if (warning === null || warning === undefined) return ''; - // Anything else (Map/Array/plain object) would collapse to "[object Object]" - // under a naive toString — JSON-serialize instead so the caller can read it. - try { - return JSON.stringify(warning); - } catch { - return String(warning); - } -} diff --git a/packages/search/lib/commands/SEARCH.spec.ts b/packages/search/lib/commands/SEARCH.spec.ts index e1f4c080bf5..449baed31e1 100644 --- a/packages/search/lib/commands/SEARCH.spec.ts +++ b/packages/search/lib/commands/SEARCH.spec.ts @@ -275,6 +275,40 @@ describe('FT.SEARCH', () => { }); }); + describe('transformReply', () => { + it('RESP2 reply has empty warnings', () => { + assert.deepEqual( + SEARCH.transformReply[2]([0]), + { total: 0, documents: [], warnings: [] } + ); + }); + + it('RESP3 reply populates warnings from the `warning` field', () => { + const reply = new Map([ + ['total_results', 0], + ['results', []], + ['warning', ['Timeout limit was reached']] + ]); + + assert.deepEqual( + SEARCH.transformReply[3](reply), + { total: 0, documents: [], warnings: ['Timeout limit was reached'] } + ); + }); + + it('RESP3 reply without a `warning` field yields empty warnings', () => { + const reply = new Map([ + ['total_results', 0], + ['results', []] + ]); + + assert.deepEqual( + SEARCH.transformReply[3](reply), + { total: 0, documents: [], warnings: [] } + ); + }); + }); + describe('client.ft.search', () => { testUtils.testWithClient('without optional options', async client => { await Promise.all([ @@ -297,7 +331,8 @@ describe('FT.SEARCH', () => { enumerable: true } }) - }] + }], + warnings: [] } ); }, GLOBAL.SERVERS.OPEN); @@ -323,7 +358,8 @@ describe('FT.SEARCH', () => { }, { id: '2', value: {} - }] + }], + warnings: [] } ); }, GLOBAL.SERVERS.OPEN); diff --git a/packages/search/lib/commands/SEARCH.ts b/packages/search/lib/commands/SEARCH.ts index 8f8da9d9bcb..0a560188000 100644 --- a/packages/search/lib/commands/SEARCH.ts +++ b/packages/search/lib/commands/SEARCH.ts @@ -3,7 +3,7 @@ import { RedisArgument, Command, ReplyUnion, TypeMapping } from '@redis/client/d import { RedisVariadicArgument, parseOptionalVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; import { RediSearchLanguage } from './CREATE'; import { DEFAULT_DIALECT } from '../dialect/default'; -import { getMapValue, mapLikeToObject, mapLikeValues, parseDocumentValue, parseSearchResultRow } from './reply-transformers'; +import { getMapValue, mapLikeToObject, mapLikeValues, parseDocumentValue, parseSearchResultRow, parseWarnings } from './reply-transformers'; export type FtSearchParams = Record; @@ -179,7 +179,9 @@ function transformSearchReplyResp2( return { total: reply[0] as number, - documents + documents, + // FT.SEARCH only emits warnings on RESP3; RESP2 replies never carry them. + warnings: [] }; } @@ -210,7 +212,8 @@ function transformSearchReplyResp3( return { total, - documents + documents, + warnings: parseWarnings(reply) }; } @@ -240,6 +243,12 @@ export interface SearchReply { id: string; value: SearchDocumentValue; }>; + /** + * Warnings returned alongside partial results (e.g. on query timeout under a + * `return` / `return-strict` on-timeout policy). Only populated on RESP3; + * always empty on RESP2. + */ + warnings: Array; } function documentValue(tuples: unknown) { diff --git a/packages/search/lib/commands/SEARCH_NOCONTENT.spec.ts b/packages/search/lib/commands/SEARCH_NOCONTENT.spec.ts index 1e0ed3d10a2..097e9238759 100644 --- a/packages/search/lib/commands/SEARCH_NOCONTENT.spec.ts +++ b/packages/search/lib/commands/SEARCH_NOCONTENT.spec.ts @@ -28,7 +28,8 @@ describe('FT.SEARCH NOCONTENT', () => { await client.ft.searchNoContent('index', '*'), { total: 2, - documents: ['1', '2'] + documents: ['1', '2'], + warnings: [] } ); }, GLOBAL.SERVERS.OPEN); diff --git a/packages/search/lib/commands/SEARCH_NOCONTENT.ts b/packages/search/lib/commands/SEARCH_NOCONTENT.ts index 635bf3b0530..9ce2977d664 100644 --- a/packages/search/lib/commands/SEARCH_NOCONTENT.ts +++ b/packages/search/lib/commands/SEARCH_NOCONTENT.ts @@ -12,7 +12,9 @@ export default { 2: (reply: SearchRawReply): SearchNoContentReply => { return { total: reply[0] as number, - documents: reply.slice(1) as Array + documents: reply.slice(1) as Array, + // FT.SEARCH only emits warnings on RESP3; RESP2 replies never carry them. + warnings: [] } }, 3: ( @@ -26,11 +28,13 @@ export default { documents: Array<{ id: string; }>; + warnings: Array; }; return { total: transformed.total, - documents: transformed.documents.map(document => document.id) + documents: transformed.documents.map(document => document.id), + warnings: transformed.warnings }; } }, @@ -39,4 +43,10 @@ export default { export interface SearchNoContentReply { total: number; documents: Array; + /** + * Warnings returned alongside partial results (e.g. on query timeout under a + * `return` / `return-strict` on-timeout policy). Only populated on RESP3; + * always empty on RESP2. + */ + warnings: Array; }; diff --git a/packages/search/lib/commands/reply-transformers.ts b/packages/search/lib/commands/reply-transformers.ts index a43f051bb31..8bd486cf1ad 100644 --- a/packages/search/lib/commands/reply-transformers.ts +++ b/packages/search/lib/commands/reply-transformers.ts @@ -57,6 +57,30 @@ export function parseDocumentValue(value: unknown): Record { return document; } +function toWarningString(warning: unknown): string { + if (typeof warning === 'string') return warning; + if (warning instanceof Buffer) return warning.toString(); + if (warning === null || warning === undefined) return ''; + // Anything else (Map/Array/plain object) would collapse to "[object Object]" + // under a naive toString — JSON-serialize instead so the caller can read it. + try { + return JSON.stringify(warning); + } catch { + return String(warning); + } +} + +/** + * Extracts the `warning` field emitted by FT.SEARCH / FT.AGGREGATE / FT.HYBRID + * alongside partial results when a query hits its timeout under a `return` / + * `return-strict` on-timeout policy. Returns an empty array when absent (e.g. + * RESP2 replies for FT.SEARCH / FT.AGGREGATE, which never carry warnings). + */ +export function parseWarnings(replyMap: Record): Array { + const warnings = mapLikeValues(getMapValue(replyMap, ['warning', 'warnings']) ?? []); + return warnings.map(toWarningString); +} + function normalizeProfileValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(normalizeProfileValue); From 3c233538d6458d9074e847bca3935bbaf5894951 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 22 Jul 2026 12:35:12 +0300 Subject: [PATCH 2/2] test(search): expect warnings field in FT.CURSOR READ reply FT.CURSOR READ reuses AGGREGATE_WITHCURSOR's transformReply, which now includes the additive warnings field. Co-Authored-By: Claude Fable 5 --- packages/search/lib/commands/CURSOR_READ.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/search/lib/commands/CURSOR_READ.spec.ts b/packages/search/lib/commands/CURSOR_READ.spec.ts index 42dca0c5756..28f9b88e190 100644 --- a/packages/search/lib/commands/CURSOR_READ.spec.ts +++ b/packages/search/lib/commands/CURSOR_READ.spec.ts @@ -38,7 +38,8 @@ describe('FT.CURSOR READ', () => { { total: 0, results: [], - cursor: 0 + cursor: 0, + warnings: [] } ); }, GLOBAL.SERVERS.OPEN);