@@ -195,28 +195,43 @@ export async function queryMembersAdvanced(
195195 // Initialize cache
196196 const cache = new MemberQueryCache ( redis )
197197
198- // Build cache key
198+ // Normalize search once: trim whitespace, lowercase (buildSearchCTE lowercases anyway),
199+ // convert empty string to null so "" and null hash identically.
200+ const normalizedSearch = search ?. trim ( ) . toLowerCase ( ) || null
201+
202+ // Full result key — includes pagination and projection so page 1 and page 2 are separate entries.
199203 const cacheKey = cache . buildCacheKey ( {
200- countOnly,
201204 fields,
202205 filter,
203206 include,
204207 includeAllAttributes,
205208 limit,
206209 offset,
207210 orderBy,
208- search,
211+ search : normalizedSearch ,
212+ segmentId,
213+ } )
214+
215+ // Count key — excludes pagination/projection and include flags since the count query
216+ // only depends on filter, search, and segmentId (buildCountQuery hardcodes includeMemberOrgs=false).
217+ const countCacheKey = cache . buildCountCacheKey ( {
218+ filter,
219+ search : normalizedSearch ,
209220 segmentId,
210221 } )
211222
212223 // Try to get from cache first
213224 const cachedResult = countOnly ? null : await cache . get ( cacheKey )
214- const cachedCount = countOnly ? null : await cache . getCount ( cacheKey )
225+ const cachedCount = countOnly ? await cache . getCount ( countCacheKey ) : null
215226
216227 if ( cachedResult ) {
228+ log . info (
229+ { cacheKey, segmentId, search : normalizedSearch , limit, offset, orderBy } ,
230+ 'Members advanced query cache hit — returning cached result, scheduling background refresh' ,
231+ )
217232 refreshCacheInBackground ( bgQx , redis , cacheKey , {
218233 filter,
219- search,
234+ search : normalizedSearch ,
220235 limit,
221236 offset,
222237 orderBy,
@@ -227,22 +242,18 @@ export async function queryMembersAdvanced(
227242 includeAllAttributes,
228243 attributeSettings,
229244 } )
230-
231- log . info ( `Members advanced query cache hit: ${ cacheKey } ` )
232245 return cachedResult
233246 }
234247
235248 if ( countOnly && cachedCount !== null ) {
236- refreshCountCacheInBackground ( bgQx , redis , cacheKey , {
237- filter,
238- search,
239- segmentId,
240- include,
241- includeAllAttributes,
242- attributeSettings,
243- } )
244-
245- log . debug ( `Members advanced count query cache hit: ${ cacheKey } ` )
249+ log . info (
250+ { countCacheKey, segmentId, search : normalizedSearch } ,
251+ 'Members advanced count cache hit — returning cached count' ,
252+ )
253+ // No background refresh for count hits: the count is a single integer with a 6h TTL,
254+ // refreshing it on every hit would fire a COUNT(*) query per request, defeating the cache.
255+ // The count is kept fresh by: (1) full-result refreshes that also write countCacheKey,
256+ // (2) natural TTL expiry, (3) explicit cache invalidation on member updates.
246257 return {
247258 rows : [ ] ,
248259 count : cachedCount ,
@@ -251,19 +262,65 @@ export async function queryMembersAdvanced(
251262 }
252263 }
253264
254- return await executeQuery ( qx , redis , cacheKey , {
255- filter,
256- search,
257- limit,
258- offset,
259- orderBy,
260- segmentId,
261- countOnly,
262- fields,
263- include,
264- includeAllAttributes,
265- attributeSettings,
266- } )
265+ log . info (
266+ {
267+ cacheKey,
268+ countCacheKey,
269+ segmentId,
270+ search : normalizedSearch ,
271+ limit,
272+ offset,
273+ orderBy,
274+ countOnly,
275+ } ,
276+ 'Members advanced query cache miss — executing query synchronously' ,
277+ )
278+
279+ try {
280+ return await executeQuery ( qx , redis , cacheKey , {
281+ filter,
282+ search : normalizedSearch ,
283+ limit,
284+ offset,
285+ orderBy,
286+ segmentId,
287+ countOnly,
288+ fields,
289+ include,
290+ includeAllAttributes,
291+ attributeSettings,
292+ } )
293+ } catch ( error ) {
294+ log . warn (
295+ { cacheKey, countCacheKey, segmentId, search : normalizedSearch , countOnly, err : error } ,
296+ 'Members advanced query failed on cache miss — scheduling background refresh for next retry' ,
297+ )
298+ if ( countOnly ) {
299+ refreshCountCacheInBackground ( bgQx , redis , countCacheKey , {
300+ filter,
301+ search : normalizedSearch ,
302+ segmentId,
303+ include,
304+ includeAllAttributes,
305+ attributeSettings,
306+ } )
307+ } else {
308+ refreshCacheInBackground ( bgQx , redis , cacheKey , {
309+ filter,
310+ search : normalizedSearch ,
311+ limit,
312+ offset,
313+ orderBy,
314+ segmentId,
315+ countOnly : false ,
316+ fields,
317+ include,
318+ includeAllAttributes,
319+ attributeSettings,
320+ } )
321+ }
322+ throw error
323+ }
267324}
268325
269326export async function executeQuery (
@@ -299,6 +356,11 @@ export async function executeQuery(
299356 } : IQueryMembersAdvancedParams ,
300357) : Promise < PageData < IDbMemberData > > {
301358 const cache = new MemberQueryCache ( redis )
359+ const countCacheKey = cache . buildCountCacheKey ( {
360+ filter,
361+ search : search ?? null ,
362+ segmentId,
363+ } )
302364 const withAggregates = ! ! segmentId
303365 const searchConfig = buildSearchCTE ( search )
304366
@@ -343,15 +405,16 @@ export async function executeQuery(
343405 withAggregates,
344406 searchConfig,
345407 filterString,
346- includeMemberOrgs : include . memberOrganizations ,
408+ // Count never needs org data in SELECT — filterHasMo inside buildCountQuery already
409+ // handles the case where the filter itself references mo.* columns.
410+ includeMemberOrgs : false ,
347411 } )
348412
349413 if ( countOnly ) {
350414 const result = await qx . selectOne ( countQuery , params )
351415 const count = parseInt ( result . count , 10 )
352416
353- // Cache the count
354- await cache . setCount ( cacheKey , count , 21600 ) // 6 hours TTL
417+ await cache . setCount ( countCacheKey , count , 21600 )
355418
356419 return {
357420 rows : [ ] ,
@@ -393,16 +456,24 @@ export async function executeQuery(
393456 offset,
394457 } )
395458
459+ // Skip the count sub-query if the count is already cached — saves a full table scan
460+ // on every page navigation after the first (page 2, 3, ... all share the same countCacheKey).
461+ const cachedCount = await cache . getCount ( countCacheKey )
396462 const [ rows , countResult ] = await Promise . all ( [
397463 qx . select ( mainQuery , params ) ,
398- qx . selectOne ( countQuery , params ) ,
464+ cachedCount === null ? qx . selectOne ( countQuery , params ) : Promise . resolve ( null ) ,
399465 ] )
400466
401- const count = parseInt ( countResult . count , 10 )
467+ const count = cachedCount !== null ? cachedCount : parseInt ( countResult ? .count ?? '0' , 10 )
402468 const memberIds = rows . map ( ( org ) => org . id )
403469
404470 if ( memberIds . length === 0 ) {
405- return { rows : [ ] , count, limit, offset }
471+ const emptyResult = { rows : [ ] , count, limit, offset }
472+ await Promise . all ( [
473+ cache . set ( cacheKey , emptyResult , 21600 ) ,
474+ cachedCount === null ? cache . setCount ( countCacheKey , count , 21600 ) : Promise . resolve ( ) ,
475+ ] )
476+ return emptyResult
406477 }
407478
408479 const [ memberOrganizations , identities , memberSegments , maintainerRoles ] = await Promise . all ( [
@@ -537,7 +608,10 @@ export async function executeQuery(
537608
538609 const result = { rows, count, limit, offset }
539610
540- await cache . set ( cacheKey , result , 21600 ) // 6 hours TTL
611+ await Promise . all ( [
612+ cache . set ( cacheKey , result , 21600 ) ,
613+ cache . setCount ( countCacheKey , count , 21600 ) ,
614+ ] )
541615
542616 return result
543617}
@@ -547,26 +621,36 @@ async function refreshCacheInBackground(
547621 redis : RedisClient ,
548622 cacheKey : string ,
549623 params : IQueryMembersAdvancedParams ,
624+ countOnly = false ,
550625) : Promise < void > {
626+ const label = countOnly ? 'count cache' : 'query cache'
627+ const cache = new MemberQueryCache ( redis )
628+ const acquired = await cache . tryAcquireRefreshLock ( cacheKey )
629+ if ( ! acquired ) {
630+ log . debug (
631+ { cacheKey } ,
632+ `Members advanced ${ label } refresh already in progress — skipping duplicate` ,
633+ )
634+ return
635+ }
551636 try {
552- await executeQuery ( qx , redis , cacheKey , params )
637+ log . info ( { cacheKey } , `Members advanced ${ label } background refresh started` )
638+ await executeQuery ( qx , redis , cacheKey , countOnly ? { ...params , countOnly : true } : params )
639+ log . info ( { cacheKey } , `Members advanced ${ label } background refresh completed` )
553640 } catch ( error ) {
554- log . warn ( 'Background cache refresh failed:' , error )
641+ log . warn ( { cacheKey, err : error } , `Members advanced ${ label } background refresh failed` )
642+ } finally {
643+ await cache . releaseRefreshLock ( cacheKey )
555644 }
556645}
557646
558- async function refreshCountCacheInBackground (
647+ function refreshCountCacheInBackground (
559648 qx : QueryExecutor ,
560649 redis : RedisClient ,
561650 cacheKey : string ,
562651 params : IQueryMembersAdvancedParams ,
563652) : Promise < void > {
564- try {
565- log . info ( `Refreshing members advanced count cache in background: ${ cacheKey } ` )
566- await executeQuery ( qx , redis , cacheKey , { ...params , countOnly : true } )
567- } catch ( error ) {
568- log . warn ( 'Background count cache refresh failed:' , error )
569- }
653+ return refreshCacheInBackground ( qx , redis , cacheKey , params , true )
570654}
571655
572656export async function queryMembers < T extends MemberField > (
0 commit comments