@@ -4,13 +4,15 @@ import {
44 DEFAULT_TENANT_ID ,
55 Error400 ,
66 RawQueryParser ,
7+ TimeoutError ,
78 generateUUIDv1 ,
9+ generateUUIDv4 ,
810 getProperDisplayName ,
911 groupBy ,
1012} from '@crowd/common'
1113import { formatSql , getDbInstance , prepareForModification } from '@crowd/database'
1214import { getServiceLogger } from '@crowd/logging'
13- import { RedisClient } from '@crowd/redis'
15+ import { RedisClient , acquireLock , releaseLock , withSingleFlight } from '@crowd/redis'
1416import {
1517 ALL_PLATFORM_TYPES ,
1618 IMemberContribution ,
@@ -37,6 +39,20 @@ import { fetchManyMemberIdentities, fetchManyMemberOrgs, fetchManyMemberSegments
3739
3840const log = getServiceLogger ( )
3941
42+ // How long a compute lock covers a legitimately slow query. Also used as the wait
43+ // timeout for requests single-flighting behind it — the two must not drift apart:
44+ // a shorter wait timeout lets waiters give up and run the query themselves while the
45+ // original holder is still legitimately within its TTL, reproducing the stampede this
46+ // lock exists to prevent. See services/libs/redis/src/singleFlight.ts for the mechanics.
47+ const COMPUTE_LOCK_TTL_SECONDS = 90
48+
49+ const toCountPage = ( count : number , limit : number , offset : number ) : PageData < IDbMemberData > => ( {
50+ rows : [ ] ,
51+ count,
52+ limit,
53+ offset,
54+ } )
55+
4056interface IQueryMembersAdvancedParams {
4157 filter ?: Record < string , unknown >
4258 search ?: string | null
@@ -255,42 +271,60 @@ export async function queryMembersAdvanced(
255271 // refreshing it on every hit would fire a COUNT(*) query per request, defeating the cache.
256272 // The count is kept fresh by: (1) full-result refreshes that also write countCacheKey,
257273 // (2) natural TTL expiry, (3) explicit cache invalidation on member updates.
258- return {
259- rows : [ ] ,
260- count : cachedCount ,
261- limit,
262- offset,
263- }
274+ return toCountPage ( cachedCount , limit , offset )
264275 }
265276
266- log . info (
267- {
268- cacheKey,
269- countCacheKey,
270- segmentId,
271- search : normalizedSearch ,
272- limit,
273- offset,
274- orderBy,
275- countOnly,
276- } ,
277- 'Members advanced query cache miss — executing query synchronously' ,
278- )
277+ // Single-flight: only the request that wins the lock hits the DB. Everyone else
278+ // piling onto the same cold key waits on the lock instead of firing the same
279+ // heavy query in parallel (this is what turns one slow query into a stampede).
280+ const computeLockKey = countOnly ? countCacheKey : cacheKey
281+
282+ const readComputeCache = async ( ) : Promise < PageData < IDbMemberData > | null > => {
283+ if ( countOnly ) {
284+ const count = await cache . getCount ( countCacheKey )
285+ return count !== null ? toCountPage ( count , limit , offset ) : null
286+ }
287+ return cache . get ( cacheKey )
288+ }
279289
280290 try {
281- return await executeQuery ( qx , redis , cacheKey , {
282- filter,
283- search : normalizedSearch ,
284- limit,
285- offset,
286- orderBy,
287- segmentId,
288- countOnly,
289- fields,
290- include,
291- includeAllAttributes,
292- attributeSettings,
293- } )
291+ return await withSingleFlight (
292+ redis ,
293+ computeLockKey ,
294+ {
295+ lockTtlSeconds : COMPUTE_LOCK_TTL_SECONDS ,
296+ waitTimeoutSeconds : COMPUTE_LOCK_TTL_SECONDS ,
297+ } ,
298+ readComputeCache ,
299+ ( ) => {
300+ log . info (
301+ {
302+ cacheKey,
303+ countCacheKey,
304+ segmentId,
305+ search : normalizedSearch ,
306+ limit,
307+ offset,
308+ orderBy,
309+ countOnly,
310+ } ,
311+ 'Members advanced query cache miss — executing query synchronously' ,
312+ )
313+ return executeQuery ( qx , redis , cacheKey , {
314+ filter,
315+ search : normalizedSearch ,
316+ limit,
317+ offset,
318+ orderBy,
319+ segmentId,
320+ countOnly,
321+ fields,
322+ include,
323+ includeAllAttributes,
324+ attributeSettings,
325+ } )
326+ } ,
327+ )
294328 } catch ( error ) {
295329 log . warn (
296330 { cacheKey, countCacheKey, segmentId, search : normalizedSearch , countOnly, err : error } ,
@@ -417,12 +451,7 @@ export async function executeQuery(
417451
418452 await cache . setCount ( countCacheKey , count , 21600 )
419453
420- return {
421- rows : [ ] ,
422- count,
423- limit,
424- offset,
425- }
454+ return toCountPage ( count , limit , offset )
426455 }
427456
428457 // Prepare fields for main query
@@ -449,7 +478,12 @@ export async function executeQuery(
449478 const mainQuery = buildQuery ( {
450479 fields : preparedFields ,
451480 withAggregates,
452- includeMemberOrgs : include . memberOrganizations ,
481+ // Org data for the returned page is always fetched separately via fetchManyMemberOrgs
482+ // below — the 'organizations' field is queryable: false, so it's never selected here.
483+ // The mo join is only needed when the filter itself references mo.*, which filterHasMo
484+ // (computed inside buildQuery) already covers — mirrors the same fix already applied
485+ // to buildCountQuery above.
486+ includeMemberOrgs : false ,
453487 searchConfig,
454488 filterString,
455489 orderBy,
@@ -625,23 +659,36 @@ async function refreshCacheInBackground(
625659 countOnly = false ,
626660) : Promise < void > {
627661 const label = countOnly ? 'count cache' : 'query cache'
628- const cache = new MemberQueryCache ( redis )
629- const acquired = await cache . tryAcquireRefreshLock ( cacheKey )
630- if ( ! acquired ) {
631- log . debug (
632- { cacheKey } ,
633- `Members advanced ${ label } refresh already in progress — skipping duplicate` ,
634- )
662+
663+ // Same lock (same key, same acquireLock/releaseLock primitive) that the synchronous
664+ // cache-miss path takes via withSingleFlight — a single non-blocking attempt (0s wait)
665+ // since this is best-effort background work: if the miss path (or another background
666+ // refresh) already holds it, just skip rather than duplicate the query. Sharing the
667+ // lock is what stops a hit-triggered refresh and a miss-triggered compute from ever
668+ // running the same heavy query concurrently for the same cache key.
669+ const token = generateUUIDv4 ( )
670+ try {
671+ await acquireLock ( redis , cacheKey , token , COMPUTE_LOCK_TTL_SECONDS , 0 )
672+ } catch ( error ) {
673+ if ( error instanceof TimeoutError ) {
674+ log . debug (
675+ { cacheKey } ,
676+ `Members advanced ${ label } refresh already in progress — skipping duplicate` ,
677+ )
678+ } else {
679+ log . warn ( { cacheKey, err : error } , `Members advanced ${ label } refresh lock unavailable` )
680+ }
635681 return
636682 }
683+
637684 try {
638685 log . info ( { cacheKey } , `Members advanced ${ label } background refresh started` )
639686 await executeQuery ( qx , redis , cacheKey , countOnly ? { ...params , countOnly : true } : params )
640687 log . info ( { cacheKey } , `Members advanced ${ label } background refresh completed` )
641688 } catch ( error ) {
642689 log . warn ( { cacheKey, err : error } , `Members advanced ${ label } background refresh failed` )
643690 } finally {
644- await cache . releaseRefreshLock ( cacheKey )
691+ await releaseLock ( redis , cacheKey , token )
645692 }
646693}
647694
0 commit comments