Skip to content

Commit 28d70de

Browse files
authored
fix: cache regression (#4269)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 1e47a19 commit 28d70de

6 files changed

Lines changed: 223 additions & 67 deletions

File tree

backend/src/database/repositories/memberRepository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {
5050
includeMemberToSegments,
5151
} from '@crowd/data-access-layer/src/members/segments'
5252
import { IDbMemberData } from '@crowd/data-access-layer/src/members/types'
53-
import { optionsQx } from '@crowd/data-access-layer/src/queryExecutor'
53+
import { optionsBgQx, optionsQx } from '@crowd/data-access-layer/src/queryExecutor'
5454
import {
5555
fetchManySegments,
5656
getSegmentMergeSuggestionCounts,
@@ -1259,7 +1259,7 @@ class MemberRepository {
12591259
let memberResponse = null
12601260

12611261
const qx = optionsQx(options)
1262-
const bgQx = optionsQx({ ...options, transaction: null })
1262+
const bgQx = optionsBgQx(options)
12631263

12641264
memberResponse = await queryMembersAdvanced(qx, bgQx, options.redis, {
12651265
filter: { id: { eq: id } },

backend/src/services/memberService.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
insertMemberSegmentAggregates,
2222
queryMembersAdvanced,
2323
} from '@crowd/data-access-layer/src/members'
24-
import { QueryExecutor, optionsQx } from '@crowd/data-access-layer/src/queryExecutor'
24+
import { QueryExecutor, optionsBgQx, optionsQx } from '@crowd/data-access-layer/src/queryExecutor'
2525
import {
2626
decrementMemberMergeSuggestionCounts,
2727
fetchManySegments,
@@ -944,7 +944,7 @@ export default class MemberService extends LoggerBase {
944944

945945
async findAllAutocomplete(data) {
946946
const qx = optionsQx(this.options)
947-
const bgQx = optionsQx({ ...this.options, transaction: null })
947+
const bgQx = optionsBgQx(this.options)
948948

949949
return queryMembersAdvanced(qx, bgQx, this.options.redis, {
950950
filter: data.filter,
@@ -970,7 +970,7 @@ export default class MemberService extends LoggerBase {
970970
}
971971

972972
const qx = optionsQx(this.options)
973-
const bgQx = optionsQx({ ...this.options, transaction: null })
973+
const bgQx = optionsBgQx(this.options)
974974

975975
return queryMembersAdvanced(qx, bgQx, this.options.redis, {
976976
...data,

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

Lines changed: 130 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -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

269326
export 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

572656
export async function queryMembers<T extends MemberField>(

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,12 +422,14 @@ export const buildQuery = ({
422422
Boolean,
423423
)
424424

425+
const needsMemberEnrichments = filterHasMe || fields.includes('me.')
426+
425427
const joins = [
426428
withAggregates
427429
? `INNER JOIN "memberSegmentsAgg" msa ON msa."memberId" = m.id AND msa."segmentId" = $(segmentId)`
428430
: '',
429431
needsMemberOrgs ? `LEFT JOIN member_orgs mo ON mo."memberId" = m.id` : '',
430-
`LEFT JOIN "memberEnrichments" me ON me."memberId" = m.id`,
432+
needsMemberEnrichments ? `LEFT JOIN "memberEnrichments" me ON me."memberId" = m.id` : '',
431433
searchConfig.join,
432434
].filter(Boolean)
433435

@@ -464,9 +466,15 @@ export const buildCountQuery = ({
464466
searchConfig.join,
465467
].filter(Boolean)
466468

469+
// COUNT(*) is safe only when every join is guaranteed one-to-one per member:
470+
// - memberSegmentsAgg is unique on (memberId, segmentId) with segmentId fixed → safe
471+
// - member_orgs CTE uses ARRAY_AGG GROUP BY memberId → one row per member → safe
472+
// - memberEnrichments may have multiple rows per member → COUNT(*) would overcount
473+
const countExpr = withAggregates && !filterHasMe ? 'COUNT(*)' : 'COUNT(DISTINCT m.id)'
474+
467475
return `
468476
${ctes.length > 0 ? `WITH ${ctes.join(',\n')}` : ''}
469-
SELECT COUNT(DISTINCT m.id) AS count
477+
SELECT ${countExpr} AS count
470478
FROM members m
471479
${joins.join('\n')}
472480
WHERE (${filterString})

0 commit comments

Comments
 (0)