Skip to content

Commit 7c5a0bb

Browse files
committed
fix: test concurrent cache-miss
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent d320aa8 commit 7c5a0bb

5 files changed

Lines changed: 202 additions & 71 deletions

File tree

services/libs/data-access-layer/src/members/base.ts

Lines changed: 95 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
1113
import { formatSql, getDbInstance, prepareForModification } from '@crowd/database'
1214
import { getServiceLogger } from '@crowd/logging'
13-
import { RedisClient } from '@crowd/redis'
15+
import { RedisClient, acquireLock, releaseLock, withSingleFlight } from '@crowd/redis'
1416
import {
1517
ALL_PLATFORM_TYPES,
1618
IMemberContribution,
@@ -37,6 +39,20 @@ import { fetchManyMemberIdentities, fetchManyMemberOrgs, fetchManyMemberSegments
3739

3840
const 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+
4056
interface 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

services/libs/data-access-layer/src/members/queryBuilder.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ const ORDER_FIELD_MAP: Record<string, string> = {
3131
displayName: 'm."displayName"',
3232
}
3333

34+
// Hard cap on candidates considered by the activityCount top-N optimization (see
35+
// buildActivityCountOptimizedQuery). Kept as a shared constant because
36+
// canUseActivityCountOptimization has to know it too, to refuse the optimized path
37+
// when a requested page would extend past the cap — otherwise a large limit/offset
38+
// (e.g. CSV exports requesting effectively unlimited rows) would come back silently
39+
// truncated instead of falling back to the exhaustive path.
40+
const ACTIVITY_COUNT_TOP_N_CAP = 50000
41+
3442
export const buildSearchCTE = (
3543
search: string,
3644
): { cte: string; join: string; params: Record<string, string> } => {
@@ -187,6 +195,9 @@ const hasNonIdMemberFieldReferences = (filterString: string): boolean => {
187195
* @param sortField - The field being sorted by (undefined means default activityCount)
188196
* @param hasEnrichmentFilters - Whether filter references me.* fields
189197
* @param hasOrganizationFilters - Whether filter references mo.* fields
198+
* @param limit - Requested page size
199+
* @param offset - Requested page offset
200+
* @param hasNonIdMemberFields - Whether filter uses m.* fields (drives oversampling)
190201
* @returns true if activityCount optimization can be used
191202
*/
192203
const canUseActivityCountOptimization = ({
@@ -195,12 +206,18 @@ const canUseActivityCountOptimization = ({
195206
includeMemberOrgs,
196207
sortField,
197208
withAggregates,
209+
limit,
210+
offset,
211+
hasNonIdMemberFields,
198212
}: {
199213
filterHasMe: boolean
200214
filterHasMo: boolean
201215
sortField: string | undefined
202216
withAggregates: boolean
203217
includeMemberOrgs: boolean
218+
limit: number
219+
offset: number
220+
hasNonIdMemberFields: boolean
204221
}): boolean => {
205222
// Need aggregates to access activityCount
206223
if (!withAggregates) return false
@@ -213,6 +230,13 @@ const canUseActivityCountOptimization = ({
213230

214231
if (includeMemberOrgs) return false
215232

233+
// The top-N CTE is capped at ACTIVITY_COUNT_TOP_N_CAP candidates. If the requested
234+
// page reaches past that cap (e.g. CSV exports requesting an effectively unlimited
235+
// number of rows), the optimized path would silently return an incomplete page —
236+
// fall back to the exhaustive path instead.
237+
const oversampleMultiplier = hasNonIdMemberFields ? 10 : 1
238+
if ((limit + offset) * oversampleMultiplier > ACTIVITY_COUNT_TOP_N_CAP) return false
239+
216240
return true
217241
}
218242

@@ -316,7 +340,7 @@ const buildActivityCountOptimizedQuery = ({
316340

317341
const baseNeeded = limit + offset
318342
const oversampleMultiplier = hasNonIdMemberFields ? 10 : 1 // 10x oversampling for m.* filters
319-
const totalNeeded = Math.min(baseNeeded * oversampleMultiplier, 50000) // Cap at 50k
343+
const totalNeeded = Math.min(baseNeeded * oversampleMultiplier, ACTIVITY_COUNT_TOP_N_CAP)
320344

321345
ctes.push(
322346
`
@@ -403,6 +427,9 @@ export const buildQuery = ({
403427
includeMemberOrgs,
404428
sortField,
405429
withAggregates,
430+
limit,
431+
offset,
432+
hasNonIdMemberFields: filterHasNonIdMemberFields,
406433
})
407434

408435
if (useActivityCountOptimized) {

services/libs/data-access-layer/src/members/queryCache.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createHash, randomBytes } from 'crypto'
1+
import { createHash } from 'crypto'
22

33
import { getServiceLogger } from '@crowd/logging'
44
import { RedisCache, RedisClient } from '@crowd/redis'
@@ -27,27 +27,6 @@ export class MemberQueryCache {
2727
this.lockCache = new RedisCache('members-refresh-lock', redis, log)
2828
}
2929

30-
// Returns true if lock was acquired (no other refresh in progress for this key).
31-
// Uses a cryptographically random token to distinguish "we set it" from "already existed".
32-
// TTL ensures the lock auto-expires if the refresh crashes without releasing it.
33-
async tryAcquireRefreshLock(cacheKey: string, ttlSeconds = 90): Promise<boolean> {
34-
try {
35-
const token = randomBytes(16).toString('hex')
36-
const stored = await this.lockCache.setIfNotExistsOrGet(cacheKey, token, ttlSeconds)
37-
return stored === token
38-
} catch {
39-
return true // fail open: if Redis is down, let the refresh proceed
40-
}
41-
}
42-
43-
async releaseRefreshLock(cacheKey: string): Promise<void> {
44-
try {
45-
await this.lockCache.delete(cacheKey)
46-
} catch {
47-
// best effort
48-
}
49-
}
50-
5130
buildCacheKey(params: {
5231
fields?: string[]
5332
filter?: Record<string, unknown>

services/libs/redis/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './cache'
66
export * from './instances'
77
export * from './rateLimiter'
88
export * from './mutex'
9+
export * from './singleFlight'
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { TimeoutError, generateUUIDv4, timeout } from '@crowd/common'
2+
3+
import { acquireLock, releaseLock } from './mutex'
4+
import { RedisClient } from './types'
5+
6+
export interface SingleFlightOptions {
7+
lockTtlSeconds: number
8+
waitTimeoutSeconds: number
9+
}
10+
11+
// 100-300ms jittered, matching mutex.ts's own retry spacing.
12+
const randomPollDelayMs = () => Math.floor(Math.random() * 200) + 100
13+
14+
/**
15+
* Ensures only one caller per `lockKey` actually runs `compute()` at a time. A caller
16+
* that loses the initial race polls `readCached()` and retries a non-blocking lock
17+
* acquisition every ~100-300ms — so it returns as soon as either the holder finishes
18+
* and populates the cache, or the lock frees up (holder crashed/released), without
19+
* waiting out the full timeout in either case. If Redis itself is unreachable, lock
20+
* acquisition fails open (proceeds unlocked) rather than failing the caller.
21+
*
22+
* `waitTimeoutSeconds` should be >= `lockTtlSeconds`: otherwise a waiter can give up
23+
* and run `compute()` unlocked while the original holder is still legitimately working
24+
* within its own TTL window, reproducing the exact stampede this helper exists to avoid.
25+
*/
26+
export async function withSingleFlight<T>(
27+
client: RedisClient,
28+
lockKey: string,
29+
{ lockTtlSeconds, waitTimeoutSeconds }: SingleFlightOptions,
30+
readCached: () => Promise<T | null>,
31+
compute: () => Promise<T>,
32+
): Promise<T> {
33+
const token = generateUUIDv4()
34+
const deadline = Date.now() + waitTimeoutSeconds * 1000
35+
let holdsLock = false
36+
37+
for (;;) {
38+
try {
39+
await acquireLock(client, lockKey, token, lockTtlSeconds, 0)
40+
holdsLock = true
41+
break
42+
} catch (error) {
43+
if (!(error instanceof TimeoutError)) {
44+
// Redis unavailable for locking — fail open, compute unprotected.
45+
break
46+
}
47+
}
48+
49+
const cached = await readCached()
50+
if (cached !== null) {
51+
return cached
52+
}
53+
54+
if (Date.now() >= deadline) {
55+
break
56+
}
57+
58+
await timeout(randomPollDelayMs())
59+
}
60+
61+
try {
62+
if (holdsLock) {
63+
// Whoever held the lock right before us may have finished and populated the
64+
// cache between our last poll and winning it — avoid a redundant compute.
65+
const cached = await readCached()
66+
if (cached !== null) {
67+
return cached
68+
}
69+
}
70+
71+
return await compute()
72+
} finally {
73+
if (holdsLock) {
74+
await releaseLock(client, lockKey, token)
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)