mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
perf: fire-and-forget reaction.toggle + replace SMEMBERS with SMISMEMBER
reaction.toggle: Return immediately after auth+rate-limit middleware. The handler runs in the background since the frontend already does optimistic updates via Zustand and ignores the response entirely. Reduces response time from 14-18s (event loop contention) to <50ms. new-order rated images: Replace SMEMBERS on sets of 39K-141K members (10-38ms blocking per call) with SMISMEMBER per batch of candidates (O(N) where N = ~200 candidates, single command, <1ms). Uses EXISTS check + sentinel member '0' to handle cache population from ClickHouse. Eliminates the dominant source of Redis slowlog entries on leader-0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Zachary Lowden
parent
b445056bac
commit
ee376ea7d8
@@ -2,10 +2,16 @@ import { toggleReactionHandler } from './../controllers/reaction.controller';
|
||||
import { toggleReactionSchema, reactionRateLimits } from './../schema/reaction.schema';
|
||||
import { router, guardedProcedure } from '~/server/trpc';
|
||||
import { rateLimit } from '~/server/middleware.trpc';
|
||||
import { handleLogError } from '~/server/utils/errorHandling';
|
||||
|
||||
export const reactionRouter = router({
|
||||
toggle: guardedProcedure
|
||||
.input(toggleReactionSchema)
|
||||
.use(rateLimit(reactionRateLimits))
|
||||
.mutation(toggleReactionHandler),
|
||||
.mutation(({ ctx, input }) => {
|
||||
// Fire-and-forget: frontend already does optimistic updates via Zustand
|
||||
// and ignores the response value entirely (no onSuccess/onError callbacks).
|
||||
// Auth + rate limit middleware have already run at this point.
|
||||
toggleReactionHandler({ ctx, input }).catch(handleLogError);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1279,7 +1279,15 @@ export async function getNewOrderRanks({ name }: { name: string }) {
|
||||
return rank;
|
||||
}
|
||||
|
||||
async function getRatedImages({
|
||||
/**
|
||||
* Ensures the rated images cache is populated for a user.
|
||||
* On cache miss, fetches from ClickHouse and populates the Redis set.
|
||||
* Returns the Redis key for subsequent SMISMEMBER checks.
|
||||
*
|
||||
* Uses a sentinel member '0' (no real image has ID 0) to distinguish
|
||||
* "cache populated but user has no ratings" from "cache not populated".
|
||||
*/
|
||||
async function ensureRatedImagesCache({
|
||||
userId,
|
||||
startAt,
|
||||
rankType,
|
||||
@@ -1292,13 +1300,9 @@ async function getRatedImages({
|
||||
|
||||
const key = `${REDIS_KEYS.NEW_ORDER.RATED}:${userId}` as const;
|
||||
|
||||
// Try to get from Redis Set first
|
||||
const cachedImageIds = await redis.sMembers(key);
|
||||
|
||||
// If cache exists, return the cached image IDs as numbers
|
||||
if (cachedImageIds && cachedImageIds.length > 0) {
|
||||
return cachedImageIds.map(Number);
|
||||
}
|
||||
// Check if cache is already populated (O(1))
|
||||
const exists = await redis.exists(key);
|
||||
if (exists) return key;
|
||||
|
||||
// Cache miss - fetch from ClickHouse
|
||||
const AND = [
|
||||
@@ -1315,12 +1319,23 @@ async function getRatedImages({
|
||||
|
||||
const imageIds = results.map((r) => r.imageId);
|
||||
|
||||
// Store in Redis Set (only if we have results to avoid empty sets)
|
||||
if (imageIds.length > 0) {
|
||||
await redis.multi().sAdd(key, imageIds.map(String)).expire(key, CacheTTL.day).exec();
|
||||
}
|
||||
// Always populate the set with a sentinel '0' so EXISTS returns true on next call.
|
||||
// Real image IDs are always > 0, so the sentinel never interferes with SMISMEMBER checks.
|
||||
const members = imageIds.length > 0 ? ['0', ...imageIds.map(String)] : ['0'];
|
||||
await redis.multi().sAdd(key, members).expire(key, CacheTTL.day).exec();
|
||||
|
||||
return imageIds;
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which image IDs have already been rated using SMISMEMBER (O(N) where N = candidates).
|
||||
* This replaces the old SMEMBERS approach which was O(M) where M = total rated images (up to 141K).
|
||||
*/
|
||||
async function filterUnratedImages(key: string, imageIds: number[]): Promise<number[]> {
|
||||
if (!redis || imageIds.length === 0) return imageIds;
|
||||
|
||||
const membership = await redis.smIsMember(key, imageIds.map(String));
|
||||
return imageIds.filter((_, i) => !membership[i]);
|
||||
}
|
||||
|
||||
// Helper function to add a newly rated image to the cache
|
||||
@@ -1408,12 +1423,15 @@ export async function getImagesQueue({
|
||||
const activeSlot = await getActiveSlot(effectiveRankType, 'rating');
|
||||
const rankPools = poolCounters[effectiveRankType][activeSlot];
|
||||
|
||||
const ratedImages = await getRatedImages({
|
||||
// Ensure the rated images cache is populated (O(1) EXISTS check, ClickHouse on miss).
|
||||
// Uses SMISMEMBER per-batch instead of SMEMBERS on the full set (which was 39K-141K members
|
||||
// for active users, taking 10-38ms and blocking the Redis shard).
|
||||
const ratedKey = await ensureRatedImagesCache({
|
||||
userId: playerId,
|
||||
startAt: player.startAt,
|
||||
rankType: player.rankType,
|
||||
});
|
||||
const seenImageIds = new Set<number>(ratedImages);
|
||||
const seenImageIds = new Set<number>();
|
||||
const isKnight = effectiveRankType === NewOrderRankType.Knight;
|
||||
|
||||
const overflowLimit = imageCount * 2;
|
||||
@@ -1434,8 +1452,15 @@ export async function getImagesQueue({
|
||||
.filter(({ score }) => (isKnight ? score < newOrderConfig.limits.knightVotes : true))
|
||||
.map(({ value }) => Number(value));
|
||||
|
||||
// Filter out already rated images and previously seen images before doing the DB query
|
||||
const unratedImageIds = imageIds.filter((id) => !seenImageIds.has(id));
|
||||
// Filter out images already seen in this request
|
||||
const unseenImageIds = imageIds.filter((id) => !seenImageIds.has(id));
|
||||
if (unseenImageIds.length === 0) {
|
||||
offset += overflowLimit;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out already rated images using SMISMEMBER (O(N) where N = candidates, single command)
|
||||
const unratedImageIds = await filterUnratedImages(ratedKey, unseenImageIds);
|
||||
if (unratedImageIds.length === 0) {
|
||||
offset += overflowLimit;
|
||||
continue;
|
||||
@@ -1453,7 +1478,7 @@ export async function getImagesQueue({
|
||||
select: { id: true, url: true, nsfwLevel: true, metadata: true },
|
||||
});
|
||||
|
||||
// Add new image IDs to the seen set
|
||||
// Add new image IDs to the seen set (for dedup within this request)
|
||||
images.forEach((image) => seenImageIds.add(image.id));
|
||||
|
||||
validatedImages.push(
|
||||
|
||||
Reference in New Issue
Block a user