refactor(ai-moderation): implement distributed locking and content-based caching

Refactors the AI moderation pipeline to improve concurrency control and
cache efficiency by moving from user-centric to content-centric caching.

- Implements a distributed locking mechanism for media analysis using
  `acquireMediaAnalysisLock` to prevent redundant LLM vision calls across
  multiple pods.
- Transitions text moderation caching from `user_mod:userId:hash` to a
  purely content-based `text_mod:hash` approach to increase hit rates.
- Enhances `getPendingMessagesByConversation` with atomic transactions
  and `FOR UPDATE SKIP LOCKED` to safely transition messages from
  `pending` to `processing` state.
- Adds `processing` status to the `AIStatus` type and database schema to
  track active analysis lifecycles.
- Implements polling logic in `llmModerationClient.ts` to wait for
  in-progress media analyses.
This commit is contained in:
MythEclipse
2026-06-05 16:56:46 +07:00
parent 09f6e80ddd
commit f057bf1f0b
4 changed files with 162 additions and 74 deletions
@@ -29,16 +29,18 @@ import {
buildStickerVisionPrompt,
} from "./stickerPrompt.js";
import {
acquireMediaAnalysisLock,
computeImagePhash,
deleteCachedMediaAnalysis,
getCachedMediaAnalysis,
getCachedMediaByPhash,
getCachedUserModeration,
getCachedTextModeration,
getRecentCorrectedModerations,
makeCustomEmojiCacheKey,
makeImageCacheKey,
makeStickerCacheKey,
makeUserModerationCacheKey,
setCachedUserModeration,
makeTextModerationCacheKey,
setCachedTextModeration,
upsertCachedMediaAnalysis,
upsertCachedMediaByPhash,
} from "./textCacheStore.js";
@@ -583,6 +585,25 @@ const analyzeSingleMediaImage = async (
: buildGeneralImageVisionPrompt(image.sourceLabel, messageId);
const visionPromise = (async (): Promise<string> => {
// Attempt to acquire DISTRIBUTED lock
// Lock expires in 60 seconds (generous timeout for LLM)
const locked = await acquireMediaAnalysisLock(cacheKey, Date.now() + 60000);
if (!locked) {
log.debug({ cacheKey }, "Media analysis distributed lock acquired by another pod. Polling...");
// Poll DB for up to 30 seconds
for (let i = 0; i < 15; i++) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const pollCached = await getCachedMediaAnalysis(cacheKey);
if (pollCached) {
visionLruCache.set(cacheKey, pollCached);
return pollCached;
}
}
log.warn({ cacheKey }, "Polling for distributed media analysis timed out. Falling back.");
return FAILED_ANALYSIS_PREFIX;
}
// Layer 2: Perceptual hash pre-check (before expensive vision API call)
let phash: string | null = null;
if (image.image_url.url.startsWith("data:")) {
@@ -680,6 +701,7 @@ const analyzeSingleMediaImage = async (
},
"Vision analysis failed after all retry attempts",
);
await deleteCachedMediaAnalysis(cacheKey).catch(() => {});
return FAILED_ANALYSIS_PREFIX;
})();
@@ -1662,7 +1684,7 @@ export async function runModerationAnalysis(
continue;
}
const cacheKey = makeUserModerationCacheKey(target.user_id, rawContent);
const cacheKey = makeTextModerationCacheKey(rawContent);
// Deduplicate: if two identical messages from same user in this batch,
// skip the cache lookup for the second and reuse the first's result.
if (seenCacheKeys.has(cacheKey)) {
@@ -1681,7 +1703,7 @@ export async function runModerationAnalysis(
seenCacheKeys.add(cacheKey);
try {
const cached = await getCachedUserModeration(cacheKey);
const cached = await getCachedTextModeration(cacheKey);
if (cached) {
// Safety: skip cache entries that are artifacts of API/parse errors.
// A previous bug cached error results as "flagged", causing 24h false positives.
@@ -1792,8 +1814,8 @@ export async function runModerationAnalysis(
// Caching a transient error would turn it into a 24h false positive.
if (result.status === "error") continue;
const cacheKey = makeUserModerationCacheKey(target.user_id, rawContent);
setCachedUserModeration(cacheKey, {
const cacheKey = makeTextModerationCacheKey(rawContent);
setCachedTextModeration(cacheKey, {
flags: result.flags ?? [],
score: result.score ?? 0,
analysis: result.analysis ?? "",