1- import { db } from '@sim/db'
21import { EMBEDDING_KEYWORD_TIN_INDEX , knowledgeBase } from '@sim/db/schema'
32import { createLogger } from '@sim/logger'
43import { getErrorMessage } from '@sim/utils/errors'
54import { inArray , sql } from 'drizzle-orm'
65import { LRUCache } from 'lru-cache'
76import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
7+ import { runSearchQuery , type SearchBudget } from '@/lib/knowledge/search/budget'
88import { tinQueryFromTsquery } from '@/lib/knowledge/search/tin-query'
99
1010const logger = createLogger ( 'TinKeywordSearch' )
@@ -16,14 +16,19 @@ const logger = createLogger('TinKeywordSearch')
1616const READINESS_TTL_MS = 60 * 1000
1717const SEARCH_INDEX_TTL_MS = 10 * 60 * 1000
1818
19- /** Whether the Tin index exists and finished building, i.e. the projection is complete. */
20- const indexReadiness = new LRUCache < 'index' , boolean > ( {
19+ /**
20+ * Whether the Tin index exists and finished building, i.e. the projection is complete. The read
21+ * spends the budget of the search that missed the cache.
22+ */
23+ const indexReadiness = new LRUCache < 'index' , boolean , SearchBudget | undefined > ( {
2124 max : 1 ,
2225 ttl : READINESS_TTL_MS ,
23- fetchMethod : async ( ) => {
24- const [ row ] = await db . execute < { valid : boolean } > ( sql `
25- SELECT i.indisvalid AS valid FROM pg_index i
26- WHERE i.indexrelid = to_regclass(${ EMBEDDING_KEYWORD_TIN_INDEX } )` )
26+ fetchMethod : async ( _key , _stale , { context } ) => {
27+ const [ row ] = await runSearchQuery ( context , 'keyword.tin_readiness' , ( executor ) =>
28+ executor . execute < { valid : boolean } > ( sql `
29+ SELECT i.indisvalid AS valid FROM pg_index i
30+ WHERE i.indexrelid = to_regclass(${ EMBEDDING_KEYWORD_TIN_INDEX } )` )
31+ )
2732 return row ?. valid === true
2833 } ,
2934} )
@@ -34,13 +39,18 @@ const searchIndexBases = new LRUCache<string, boolean>({
3439 ttl : SEARCH_INDEX_TTL_MS ,
3540} )
3641
37- async function allSearchIndexes ( knowledgeBaseIds : readonly string [ ] ) : Promise < boolean > {
42+ async function allSearchIndexes (
43+ knowledgeBaseIds : readonly string [ ] ,
44+ budget : SearchBudget | undefined
45+ ) : Promise < boolean > {
3846 const unknown = knowledgeBaseIds . filter ( ( id ) => searchIndexBases . get ( id ) === undefined )
3947 if ( unknown . length > 0 ) {
40- const rows = await db
41- . select ( { id : knowledgeBase . id , isSearchIndex : knowledgeBase . isSearchIndex } )
42- . from ( knowledgeBase )
43- . where ( inArray ( knowledgeBase . id , unknown ) )
48+ const rows = await runSearchQuery ( budget , 'keyword.tin_readiness' , ( executor ) =>
49+ executor
50+ . select ( { id : knowledgeBase . id , isSearchIndex : knowledgeBase . isSearchIndex } )
51+ . from ( knowledgeBase )
52+ . where ( inArray ( knowledgeBase . id , unknown ) )
53+ )
4454 for ( const row of rows ) searchIndexBases . set ( row . id , row . isSearchIndex )
4555 }
4656 return knowledgeBaseIds . every ( ( id ) => searchIndexBases . get ( id ) === true )
@@ -52,22 +62,31 @@ async function allSearchIndexes(knowledgeBaseIds: readonly string[]): Promise<bo
5262 * base is not an organization search index (only those are projected), or the query uses a shape
5363 * TINQL cannot express. The text is analyzed by the same `websearch_to_tsquery` the GIN path
5464 * uses, so both engines match the same stemmed terms.
65+ *
66+ * Every read runs under the keyword leg's `budget`, so deciding the engine cannot outlast the leg's
67+ * deadline. A read that fails for another reason, including a shared cache read cut short by
68+ * another search's deadline, keeps the GIN projection; this search's own expired deadline or
69+ * cancellation propagates like any other keyword query's.
5570 */
5671export async function resolveTinKeywordQuery (
5772 knowledgeBaseIds : readonly string [ ] ,
5873 query : string ,
59- ftsConfig : string
74+ ftsConfig : string ,
75+ budget : SearchBudget | undefined
6076) : Promise < string | null > {
6177 if ( knowledgeBaseIds . length === 0 ) return null
6278 try {
6379 if ( ! ( await isFeatureEnabled ( 'knowledge-tin-keyword' ) ) ) return null
64- if ( ! ( await indexReadiness . fetch ( 'index' ) ) ) return null
65- if ( ! ( await allSearchIndexes ( knowledgeBaseIds ) ) ) return null
66- const [ { rendered } ] = await db . execute < { rendered : string } > (
67- sql `SELECT websearch_to_tsquery(${ ftsConfig } ::regconfig, ${ query } )::text AS rendered`
80+ if ( ! ( await indexReadiness . fetch ( 'index' , { context : budget } ) ) ) return null
81+ if ( ! ( await allSearchIndexes ( knowledgeBaseIds , budget ) ) ) return null
82+ const [ { rendered } ] = await runSearchQuery ( budget , 'keyword.tin_query' , ( executor ) =>
83+ executor . execute < { rendered : string } > (
84+ sql `SELECT websearch_to_tsquery(${ ftsConfig } ::regconfig, ${ query } )::text AS rendered`
85+ )
6886 )
6987 return tinQueryFromTsquery ( rendered )
7088 } catch ( error ) {
89+ budget ?. remaining ( )
7190 logger . warn ( 'Tin keyword readiness check failed; using the GIN projection' , {
7291 error : getErrorMessage ( error ) ,
7392 } )
0 commit comments