Implements slot queue system for kono (#1900)

* Implements slot queue system for kono

* Applies code review feedback

* More cleanup

* optimize judgment counter queries by replacing FINAL with argMax
This commit is contained in:
Manuel Emilio Urena
2025-11-18 16:53:57 -04:00
committed by GitHub
parent f976489fab
commit 06f9217781
8 changed files with 585 additions and 159 deletions
+120
View File
@@ -0,0 +1,120 @@
-- Redis Commands for KONO Slot Queue Migration
-- Execute these commands in sequence via redis-cli
-- IMPORTANT: Run during low-traffic period (e.g., 02:00 UTC)
-- ============================================
-- STEP 1: Migrate existing queues to slot 'a'
-- ============================================
-- Acolyte queues
RENAME new-order:queues:Acolyte1 new-order:queues:Acolyte1:a
RENAME new-order:queues:Acolyte2 new-order:queues:Acolyte2:a
RENAME new-order:queues:Acolyte3 new-order:queues:Acolyte3:a
-- Knight queues
RENAME new-order:queues:Knight1 new-order:queues:Knight1:a
RENAME new-order:queues:Knight2 new-order:queues:Knight2:a
RENAME new-order:queues:Knight3 new-order:queues:Knight3:a
-- Templar queues
RENAME new-order:queues:Templar1 new-order:queues:Templar1:a
RENAME new-order:queues:Templar2 new-order:queues:Templar2:a
RENAME new-order:queues:Templar3 new-order:queues:Templar3:a
-- Inquisitor queue (moderator queue)
RENAME new-order:queues:Inquisitor new-order:queues:Inquisitor:a
-- ============================================
-- STEP 2: Initialize active slot pointers
-- ============================================
-- Acolyte slot pointers (both start at 'a')
SET new-order:active-slot:Acolyte:filling "a"
SET new-order:active-slot:Acolyte:rating "a"
-- Knight slot pointers
SET new-order:active-slot:Knight:filling "a"
SET new-order:active-slot:Knight:rating "a"
-- Templar slot pointers
SET new-order:active-slot:Templar:filling "a"
SET new-order:active-slot:Templar:rating "a"
-- Inquisitor slot pointers
SET new-order:active-slot:Inquisitor:filling "a"
SET new-order:active-slot:Inquisitor:rating "a"
-- ============================================
-- STEP 3: Verification Commands
-- ============================================
-- Check migrated queues (slot 'a')
ZCARD new-order:queues:Acolyte1:a
ZCARD new-order:queues:Acolyte2:a
ZCARD new-order:queues:Acolyte3:a
ZCARD new-order:queues:Knight1:a
ZCARD new-order:queues:Knight2:a
ZCARD new-order:queues:Knight3:a
ZCARD new-order:queues:Templar1:a
ZCARD new-order:queues:Templar2:a
ZCARD new-order:queues:Templar3:a
ZCARD new-order:queues:Inquisitor:a
-- Verify slot 'b' is empty (should return 0 or nil)
EXISTS new-order:queues:Knight1:b
EXISTS new-order:queues:Knight2:b
EXISTS new-order:queues:Knight3:b
-- Check slot pointers
GET new-order:active-slot:Acolyte:filling
GET new-order:active-slot:Acolyte:rating
GET new-order:active-slot:Knight:filling
GET new-order:active-slot:Knight:rating
GET new-order:active-slot:Templar:filling
GET new-order:active-slot:Templar:rating
GET new-order:active-slot:Inquisitor:filling
GET new-order:active-slot:Inquisitor:rating
-- Verify old keys are gone
EXISTS new-order:queues:Knight1
EXISTS new-order:queues:Templar1
EXISTS new-order:queues:Inquisitor
-- ============================================
-- OPTIONAL: Manual Slot Rotation (Testing)
-- ============================================
-- To manually trigger fill slot rotation (normally at 22:00 UTC):
-- SET new-order:active-slot:Knight:filling "b"
-- SET new-order:active-slot:Templar:filling "b"
-- SET new-order:active-slot:Inquisitor:filling "b"
-- To manually trigger rate slot rotation (normally at 00:00 UTC):
-- SET new-order:active-slot:Knight:rating "b"
-- SET new-order:active-slot:Templar:rating "b"
-- SET new-order:active-slot:Inquisitor:rating "b"
-- ============================================
-- ROLLBACK COMMANDS (Emergency Use Only)
-- ============================================
-- If migration needs to be rolled back:
-- RENAME new-order:queues:Knight1:a new-order:queues:Knight1
-- RENAME new-order:queues:Knight2:a new-order:queues:Knight2
-- RENAME new-order:queues:Knight3:a new-order:queues:Knight3
-- RENAME new-order:queues:Templar1:a new-order:queues:Templar1
-- RENAME new-order:queues:Templar2:a new-order:queues:Templar2
-- RENAME new-order:queues:Templar3:a new-order:queues:Templar3
-- RENAME new-order:queues:Inquisitor:a new-order:queues:Inquisitor
-- Delete slot pointers:
-- DEL new-order:active-slot:Knight:filling
-- DEL new-order:active-slot:Knight:rating
-- DEL new-order:active-slot:Templar:filling
-- DEL new-order:active-slot:Templar:rating
-- DEL new-order:active-slot:Inquisitor:filling
-- DEL new-order:active-slot:Inquisitor:rating
+47 -13
View File
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import * as z from 'zod';
import { poolCounters, blessedBuzzCounter } from '~/server/games/new-order/utils';
import { poolCounters, blessedBuzzCounter, getActiveSlot } from '~/server/games/new-order/utils';
import { addImageToQueue, getImagesQueue } from '~/server/services/games/new-order.service';
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
import { NewOrderRankType } from '~/shared/utils/prisma/enums';
@@ -78,11 +78,26 @@ export default WebhookEndpoint(async function (req: NextApiRequest, res: NextApi
return rankType ? rank === rankType : true;
})
.map(async (rank) => {
const rankKey = rank as NewOrderRankType;
// Get both slots for this rank
const slotAQueues = await Promise.all(poolCounters[rankKey].a.map((p) => p.getAll()));
const slotBQueues = await Promise.all(poolCounters[rankKey].b.map((p) => p.getAll()));
// Get active slot pointers
const fillingSlot = await getActiveSlot(rankKey, 'filling');
const ratingSlot = await getActiveSlot(rankKey, 'rating');
return {
rank,
queues: await Promise.all(
poolCounters[rank as NewOrderRankType].map((p) => p.getAll({ limit: 20000 }))
),
activeSlots: {
filling: fillingSlot,
rating: ratingSlot,
},
slots: {
a: slotAQueues,
b: slotBQueues,
},
};
})
);
@@ -93,16 +108,28 @@ export default WebhookEndpoint(async function (req: NextApiRequest, res: NextApi
if (action === 'remove-from-queue') {
const { rankType, limit } = payload;
// Fetch current image IDs from the rankType queue
const currentImageIds = (
// Fetch current image IDs from both slots
const slotAImageIds = (
await Promise.all(
poolCounters[rankType as NewOrderRankType].map((pool) => pool.getAll({ limit }))
poolCounters[rankType as NewOrderRankType].a.map((pool) => pool.getAll({ limit }))
)
)
.flat()
.map((value) => Number(value));
const slotBImageIds = (
await Promise.all(
poolCounters[rankType as NewOrderRankType].b.map((pool) => pool.getAll({ limit }))
)
)
.flat()
.map((value) => Number(value));
const currentImageIds = [...slotAImageIds, ...slotBImageIds];
const chunks = chunk(currentImageIds, 1000);
let removedCount = 0;
for (const chunk of chunks) {
// Check against the database to find non-existing image IDs
const existingImages = await dbRead.image.findMany({
@@ -120,22 +147,29 @@ export default WebhookEndpoint(async function (req: NextApiRequest, res: NextApi
);
if (imageIdsToRemove.length === 0) continue;
// Remove non-existing images from the queue
await Promise.all(
poolCounters[rankType as NewOrderRankType].map((pool) =>
removedCount += imageIdsToRemove.length;
// Remove from both slots
await Promise.all([
...poolCounters[rankType as NewOrderRankType].a.map((pool) =>
pool.reset({ id: imageIdsToRemove })
)
);
),
...poolCounters[rankType as NewOrderRankType].b.map((pool) =>
pool.reset({ id: imageIdsToRemove })
),
]);
}
return res.status(200).json({
message: 'Non-existing images removed from queue successfully',
removedCount,
checkedSlots: ['a', 'b'],
});
}
if (action === 'get-blessed-buzz') {
// Retrieve all entries with their scores
const allEntries = await blessedBuzzCounter.getAll({ withCount: true, limit: 100000 });
const allEntries = await blessedBuzzCounter.getAll({ withCount: true });
// Filter out entries with negative values
const filtered = allEntries.filter((entry) => Number(entry.score) < 0);
return res.status(200).json({ results: filtered });
+179 -67
View File
@@ -1,4 +1,5 @@
import dayjs from 'dayjs';
import { values } from 'idb-keyval';
import { clickhouse } from '~/server/clickhouse/client';
import { CacheTTL } from '~/server/common/constants';
import { NewOrderImageRatingStatus } from '~/server/common/enums';
@@ -44,25 +45,25 @@ function createCounter({ key, fetchCount, ttl = CacheTTL.day, ordered }: Counter
withCount: true;
}): Promise<{ score: number; value: string }[]>;
async function getAll(opts?: { limit?: number; offset?: number; withCount?: boolean }) {
const { limit = 100, offset = 0, withCount = false } = opts ?? {};
const { limit, offset = 0, withCount = false } = opts ?? {};
// Returns all ids in the range of min and max
// If ordered, returns the ids by the score in descending order.
if (ordered) {
const data = await sysRedis.zRangeWithScores(key, Infinity, -Infinity, {
BY: 'SCORE',
REV: true,
LIMIT: { offset, count: limit },
LIMIT: limit ? { offset, count: limit } : undefined,
});
return withCount ? data : data.map((x) => x.value);
}
const data = await sysRedis.hGetAll(key);
return withCount
? Object.entries(data)
.map(([value, score]) => ({ score, value }))
.slice(offset, offset + limit)
: Object.values(data).slice(offset, offset + limit);
const entries = withCount
? Object.entries(data).map(([value, score]) => ({ value, score: Number(score) }))
: Object.values(data);
return limit ? entries.slice(offset, offset + limit) : entries;
}
async function getCount(id: number | string) {
@@ -139,13 +140,20 @@ export const correctJudgmentsCounter = createCounter({
const sevenDaysAgo = dayjs().subtract(7, 'days').toDate();
const effectiveStartDate = player.startAt > sevenDaysAgo ? player.startAt : sevenDaysAgo;
// Optimized query using argMax instead of FINAL for better performance
// Groups by imageId to get latest status per rating, then counts Correct ones
const data = await clickhouse.$query<{ count: number }>`
SELECT
COUNT(*) as count
FROM knights_new_order_image_rating
WHERE userId = ${id}
AND createdAt >= ${effectiveStartDate}
AND status = '${NewOrderImageRatingStatus.Correct}'
SELECT COUNT(*) as count
FROM (
SELECT
imageId,
argMax(status, createdAt) as latest_status
FROM knights_new_order_image_rating
WHERE userId = ${id}
AND createdAt >= ${effectiveStartDate}
GROUP BY imageId
)
WHERE latest_status = '${NewOrderImageRatingStatus.Correct}'
`;
if (!data) return 0;
@@ -169,13 +177,20 @@ export const allJudgmentsCounter = createCounter({
const sevenDaysAgo = dayjs().subtract(7, 'days').toDate();
const effectiveStartDate = player.startAt > sevenDaysAgo ? player.startAt : sevenDaysAgo;
// Optimized query using argMax instead of FINAL for better performance
// Groups by imageId to get latest status per rating, then counts finalized judgments
const data = await clickhouse.$query<{ count: number }>`
SELECT
COUNT(*) as count
FROM knights_new_order_image_rating
WHERE userId = ${id}
AND createdAt >= ${effectiveStartDate}
AND status IN ('${NewOrderImageRatingStatus.Correct}', '${NewOrderImageRatingStatus.Failed}', '${NewOrderImageRatingStatus.Inconclusive}')
SELECT COUNT(*) as count
FROM (
SELECT
imageId,
argMax(status, createdAt) as latest_status
FROM knights_new_order_image_rating
WHERE userId = ${id}
AND createdAt >= ${effectiveStartDate}
GROUP BY imageId
)
WHERE latest_status IN ('${NewOrderImageRatingStatus.Correct}', '${NewOrderImageRatingStatus.Failed}', '${NewOrderImageRatingStatus.Inconclusive}')
`;
if (!data) return 0;
@@ -244,59 +259,156 @@ export const expCounter = createCounter({
});
export const poolKeys = {
[NewOrderRankType.Acolyte]: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte1`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte2`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte3`,
],
[NewOrderRankType.Knight]: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight1`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight2`,
// Temporarily disabled Knight3 queue
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight3`,
],
[NewOrderRankType.Templar]: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar1`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar2`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar3`,
],
[NewOrderRankType.Acolyte]: {
a: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte1:a`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte2:a`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte3:a`,
],
b: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte1:b`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte2:b`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Acolyte3:b`,
],
},
[NewOrderRankType.Knight]: {
a: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight1:a`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight2:a`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight3:a`,
],
b: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight1:b`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight2:b`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Knight3:b`,
],
},
[NewOrderRankType.Templar]: {
a: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar1:a`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar2:a`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar3:a`,
],
b: [
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar1:b`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar2:b`,
`${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Templar3:b`,
],
},
};
export const poolCounters = {
[NewOrderRankType.Acolyte]: poolKeys[NewOrderRankType.Acolyte].map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
})
),
[NewOrderRankType.Knight]: poolKeys[NewOrderRankType.Knight].map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0, // Changed from CacheTTL.week - cleaned by newOrderCleanupQueues job
ordered: true,
})
),
[NewOrderRankType.Templar]: poolKeys[NewOrderRankType.Templar].map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0, // Changed from CacheTTL.week - cleaned by newOrderCleanupQueues job
ordered: true,
})
),
Inquisitor: [
createCounter({
key: `${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Inquisitor`,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
}),
],
[NewOrderRankType.Acolyte]: {
a: poolKeys[NewOrderRankType.Acolyte].a.map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
})
),
b: poolKeys[NewOrderRankType.Acolyte].b.map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
})
),
},
[NewOrderRankType.Knight]: {
a: poolKeys[NewOrderRankType.Knight].a.map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
})
),
b: poolKeys[NewOrderRankType.Knight].b.map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
})
),
},
[NewOrderRankType.Templar]: {
a: poolKeys[NewOrderRankType.Templar].a.map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
})
),
b: poolKeys[NewOrderRankType.Templar].b.map((key) =>
createCounter({
key: key as NewOrderRedisKey,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
})
),
},
Inquisitor: {
a: [
createCounter({
key: `${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Inquisitor:a`,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
}),
],
b: [
createCounter({
key: `${REDIS_SYS_KEYS.NEW_ORDER.QUEUES}:Inquisitor:b`,
fetchCount: async () => 0,
ttl: 0,
ordered: true,
}),
],
},
};
type NewOrderSlot = 'a' | 'b';
export type NewOrderHighRankType = NewOrderRankType | 'Inquisitor';
/**
* Get the currently active slot for a rank and purpose (filling or rating)
* @param rank - The rank type to check
* @param purpose - Whether this is for filling (adding images) or rating (fetching images)
* @returns The active slot ('a' or 'b')
*/
export async function getActiveSlot(
rank: NewOrderHighRankType,
purpose: 'filling' | 'rating'
): Promise<NewOrderSlot> {
if (!sysRedis) return 'a'; // Default fallback
const key = `${REDIS_SYS_KEYS.NEW_ORDER.ACTIVE_SLOT}:${rank}:${purpose}` as const;
const slot = await sysRedis.get(key);
return (slot as NewOrderSlot) || 'a'; // Default to 'a' if not set
}
/**
* Set the active slot for a rank and purpose
* @param rank - The rank type to update
* @param purpose - Whether this is for filling or rating
* @param slot - The slot to set as active ('a' or 'b')
*/
export async function setActiveSlot(
rank: NewOrderHighRankType,
purpose: 'filling' | 'rating',
slot: NewOrderSlot
): Promise<void> {
if (!sysRedis) return;
const key = `${REDIS_SYS_KEYS.NEW_ORDER.ACTIVE_SLOT}:${rank}:${purpose}` as const;
await sysRedis.set(key, slot);
}
export const getImageRatingsCounter = (imageId: number) => {
const key = `${REDIS_SYS_KEYS.NEW_ORDER.RATINGS}:${imageId}`;
const counter = createCounter({
+165 -32
View File
@@ -10,12 +10,19 @@ import {
correctJudgmentsCounter,
expCounter,
fervorCounter,
getActiveSlot,
poolCounters,
setActiveSlot,
} from '~/server/games/new-order/utils';
import { createJob } from '~/server/jobs/job';
import { TransactionType } from '~/shared/constants/buzz.constants';
import { createBuzzTransactionMany } from '~/server/services/buzz.service';
import { calculateFervor, cleanseSmite } from '~/server/services/games/new-order.service';
import {
calculateFervor,
cleanseSmite,
clearRatedImages,
processFinalRatings,
} from '~/server/services/games/new-order.service';
import { limitConcurrency } from '~/server/utils/concurrency-helpers';
import { NewOrderRankType } from '~/shared/utils/prisma/enums';
import { createLogger } from '~/utils/logging';
@@ -195,6 +202,9 @@ const newOrderDailyReset = createJob('new-order-daily-reset', '0 0 * * *', async
WHERE "NewOrderPlayer"."userId" = affected."userId"
`;
// Step 4: Clear rated images cache for all players in this batch
await Promise.all(batch.map((player) => clearRatedImages(player.userId)));
log(`DailyReset:: Batch ${batchCount} of ${batches.length} complete`);
batchCount++;
}
@@ -240,46 +250,169 @@ const newOrderCleanupQueues = createJob('new-order-cleanup-queues', '*/10 * * *
for (const rank of ranksToClean) {
log(`CleanupQueues :: Cleaning up ${rank} queues`);
// Fetch current image IDs from the rankType queue
const currentImageIds = (
await Promise.all(poolCounters[rank].map((pool) => pool.getAll({ limit: 10000 })))
)
.flat()
.map((value) => Number(value));
// Clean up both slots (a and b)
for (const slot of ['a', 'b'] as const) {
log(`CleanupQueues :: Cleaning up ${rank} slot ${slot}`);
if (currentImageIds.length === 0) {
log(`CleanupQueues :: No images found for ${rank}`);
continue;
}
// Fetch current image IDs from the rankType queue slot
const currentImageIds = (
await Promise.all(poolCounters[rank][slot].map((pool) => pool.getAll()))
)
.flat()
.map((value) => Number(value));
const chunks = chunk(currentImageIds, 1000);
for (const chunk of chunks) {
// Check against the database to find non-existing image IDs
const existingImages = await dbRead.image.findMany({
where: { id: { in: chunk } },
select: { id: true, nsfwLevel: true },
});
const existingImageIds = new Set(existingImages.map((image) => image.id));
const blockedImageIds = new Set(
existingImages
.filter((image) => image.nsfwLevel === NsfwLevel.Blocked)
.map((image) => image.id)
);
const imageIdsToRemove = chunk.filter(
(id) => !existingImageIds.has(id) || blockedImageIds.has(id)
);
if (imageIdsToRemove.length === 0) continue;
if (currentImageIds.length === 0) {
log(`CleanupQueues :: No images found for ${rank} slot ${slot}`);
continue;
}
// Remove non-existing images from the queue
await Promise.all([poolCounters[rank].map((pool) => pool.reset({ id: imageIdsToRemove }))]);
const chunks = chunk(currentImageIds, 1000);
for (const chunkData of chunks) {
// Check against the database to find non-existing image IDs
const existingImages = await dbRead.image.findMany({
where: { id: { in: chunkData } },
select: { id: true, nsfwLevel: true },
});
const existingImageIds = new Set(existingImages.map((image) => image.id));
const blockedImageIds = new Set(
existingImages
.filter((image) => image.nsfwLevel === NsfwLevel.Blocked)
.map((image) => image.id)
);
const imageIdsToRemove = chunkData.filter(
(id) => !existingImageIds.has(id) || blockedImageIds.has(id)
);
if (imageIdsToRemove.length === 0) continue;
// Remove non-existing images from the queue slot
await Promise.all(
poolCounters[rank][slot].map((pool) => pool.reset({ id: imageIdsToRemove }))
);
}
}
}
log('CleanupQueues :: Cleaning up queues :: done');
});
// Rotate filling slot at 22:00 UTC daily
// All new images will be added to the new slot
const newOrderChangeFillTarget = createJob(
'new-order-change-fill-target',
'0 22 * * *',
async () => {
log('ChangeFillTarget :: Starting fill slot rotation');
// Only Knight rank uses slot rotation; other ranks remain on a single slot
const ranksToRotate = [NewOrderRankType.Knight] as const;
for (const rank of ranksToRotate) {
const currentSlot = await getActiveSlot(rank, 'filling');
const newSlot = currentSlot === 'a' ? 'b' : 'a';
await setActiveSlot(rank, 'filling', newSlot);
log(`ChangeFillTarget :: ${rank} filling slot rotated: ${currentSlot}${newSlot}`);
}
log('ChangeFillTarget :: Fill slot rotation complete');
}
);
// Rotate rating slot and purge old slot at 00:00 UTC daily
// Players will now rate from the new slot, old slot gets purged
const newOrderChangeRateTarget = createJob(
'new-order-change-rate-target',
'0 0 * * *',
async () => {
if (!clickhouse) {
log('ChangeRateTarget :: ClickHouse not available, skipping');
return;
}
log('ChangeRateTarget :: Starting rate slot rotation and purge');
// Only Knight rank uses slot rotation; other ranks remain on a single slot
const ranksToRotate = [NewOrderRankType.Knight] as const;
for (const rank of ranksToRotate) {
const currentSlot = await getActiveSlot(rank, 'rating');
const newSlot = currentSlot === 'a' ? 'b' : 'a';
// Rotate to the new slot
await setActiveSlot(rank, 'rating', newSlot);
log(`ChangeRateTarget :: ${rank} rating slot rotated: ${currentSlot}${newSlot}`);
log(`ChangeRateTarget :: ${rank} - Purging old slot ${currentSlot} before rotation`);
// Get all image IDs from the current (soon to be old) rating slot
// No limit - process all images in the slot
const imagesToPurge = (
await Promise.all(poolCounters[rank][currentSlot].map((pool) => pool.getAll()))
)
.flat()
.map((value) => Number(value));
if (imagesToPurge.length > 0) {
log(
`ChangeRateTarget :: ${rank} - Found ${imagesToPurge.length} images to purge from slot ${currentSlot}`
);
// Mark images as Inconclusive by inserting NULL ratings into buffer
// Images still in queue at purge time legitimately didn't reach consensus
// (images with consensus were already removed via removeImageFromQueue)
log(`ChangeRateTarget :: ${rank} - Inserting NULL ratings into buffer for processing`);
const batches = chunk(imagesToPurge, 10000);
for (const batch of batches) {
// Insert NULL ratings into buffer - these will be marked as Inconclusive by processFinalRatings
const bufferRecords = batch.map((imageId) => ({
imageId,
rating: null,
}));
await clickhouse.insert({
table: 'knights_rating_updates_buffer',
values: bufferRecords,
format: 'JSONEachRow',
});
}
log(
`ChangeRateTarget :: ${rank} - Inserted ${imagesToPurge.length} NULL ratings into buffer`
);
// Process the ratings through the standard pipeline
// This will mark them as Inconclusive using the same logic as regular ratings
const result = await processFinalRatings();
log(
`ChangeRateTarget :: ${rank} - processFinalRatings result: ${JSON.stringify(
result,
null,
2
)}`
);
log(
`ChangeRateTarget :: ${rank} - Processed ${imagesToPurge.length} images as Inconclusive`
);
// Clear all pools in the old slot
await Promise.all(poolCounters[rank][currentSlot].map((pool) => pool.reset({ all: true })));
log(`ChangeRateTarget :: ${rank} - Cleared all pools in slot ${currentSlot}`);
} else {
log(`ChangeRateTarget :: ${rank} - No images to purge from slot ${currentSlot}`);
}
}
log('ChangeRateTarget :: Rate slot rotation and purge complete');
}
);
export const newOrderJobs = [
newOrderGrantBlessedBuzz,
newOrderDailyReset, // Re-enabled with Redis counter sync
newOrderDailyReset,
newOrderCleanseSmites,
newOrderCleanupQueues,
// newOrderCleanupQueues,
newOrderChangeFillTarget,
newOrderChangeRateTarget,
];
@@ -2,6 +2,16 @@ import { NotificationCategory } from '~/server/common/enums';
import { createNotificationProcessor } from '~/server/notifications/base.notifications';
export const knightsNewOrderNotifications = createNotificationProcessor({
'new-order-sanity-warning': {
displayName: 'Knights of New Order: Performance Check Failed',
category: NotificationCategory.Other,
prepareMessage: ({ details }) => ({
message:
(details?.message as string) ||
'Knights of New Order: Careful! You incorrectly rated a performance check image. Make sure you rate based on your acolyte training to avoid getting a Smite.',
url: '/games/knights-of-new-order',
}),
},
'new-order-smite-received': {
displayName: 'Knights of New Order: Smite',
prepareMessage: () => ({
+1
View File
@@ -479,6 +479,7 @@ export const REDIS_SYS_KEYS = {
BUZZ: 'new-order:blessed-buzz',
SMITE: 'new-order:smite-progress',
QUEUES: 'new-order:queues',
ACTIVE_SLOT: 'new-order:active-slot',
RATINGS: 'new-order:ratings',
MATCHES: 'new-order:matches',
JUDGEMENTS: {
+59 -45
View File
@@ -11,6 +11,7 @@ import {
SignalTopic,
} from '~/server/common/enums';
import { dbRead, dbWrite } from '~/server/db/client';
import type { NewOrderHighRankType } from '~/server/games/new-order/utils';
import {
acolyteFailedJudgments,
allJudgmentsCounter,
@@ -19,6 +20,7 @@ import {
correctJudgmentsCounter,
expCounter,
fervorCounter,
getActiveSlot,
getImageRatingsCounter,
poolCounters,
sanityCheckFailuresCounter,
@@ -60,8 +62,6 @@ import { getRandom, shuffle } from '~/utils/array-helpers';
import { signalClient } from '~/utils/signal-client';
import { isDefined } from '~/utils/type-guards';
type NewOrderHighRankType = NewOrderRankType | 'Inquisitor';
const ACOLYTE_WRONG_ANSWER_LIMIT = 5;
// Helper functions for atomic voting
@@ -784,7 +784,7 @@ export async function updatePendingImageRatings({
const PROCESS_MIN = 100; // number of items that will trigger early processing
const PROCESS_INTERVAL = 30; // seconds
async function processFinalRatings() {
export async function processFinalRatings() {
if (!clickhouse || !sysRedis) throw throwInternalServerError('Not supported');
// Increment process requests
@@ -1292,7 +1292,7 @@ async function addRatedImage(userId: number, imageId: number) {
}
// Helper function to clear rated images cache for a player
async function clearRatedImages(userId: number) {
export async function clearRatedImages(userId: number) {
if (!redis) return;
const key = `${REDIS_KEYS.NEW_ORDER.RATED}:${userId}` as const;
@@ -1318,7 +1318,10 @@ export async function addImageToQueue({
});
if (images.length === 0) return false;
const pools = poolCounters[rankType];
// Get active filling slot for this rank
const activeSlot = await getActiveSlot(rankType, 'filling');
const pools = poolCounters[rankType][activeSlot];
await Promise.all(
images.map((image) => {
const pool = pools[priority - 1];
@@ -1359,7 +1362,10 @@ export async function getImagesQueue({
// Moderators can specify queueType to test different queues (Acolyte or Knight only)
// Regular players always use their current rank
const effectiveRankType = isModerator && queueType ? queueType : player.rankType;
const rankPools = poolCounters[effectiveRankType];
// Get active rating slot for this rank
const activeSlot = await getActiveSlot(effectiveRankType, 'rating');
const rankPools = poolCounters[effectiveRankType][activeSlot];
const ratedImages = await getRatedImages({
userId: playerId,
@@ -1533,17 +1539,18 @@ export async function isImageInQueue({
rankType: NewOrderHighRankType | NewOrderHighRankType[];
}) {
if (!Array.isArray(rankType)) rankType = [rankType];
const pools = rankType
.map((rank) =>
poolCounters[rank].map((pool) => ({
pool,
rank,
}))
)
.flat();
// Check both slots (a and b) for each rank
const pools = rankType.flatMap((rank) => {
const slots = poolCounters[rank];
return [
...slots.a.map((pool) => ({ pool, rank, slot: 'a' as const })),
...slots.b.map((pool) => ({ pool, rank, slot: 'b' as const })),
];
});
const exists = await Promise.all(
pools.map(async ({ pool, rank }) => {
pools.map(async ({ pool, rank, slot }) => {
const exists = await pool.exists(imageId);
if (exists) {
const value = await pool.getCount(imageId);
@@ -1551,6 +1558,7 @@ export async function isImageInQueue({
pool,
value,
rank,
slot,
};
}
return null;
@@ -1923,9 +1931,8 @@ export async function submitTestVote({
});
await updatePendingImageRatings({ imageId, rating: consensus });
await valueInQueue!.pool.reset({ id: imageId });
await getImageRatingsCounter(imageId).reset({ all: true }); // Clear vote distribution
await notifyQueueUpdate(valueInQueue!.rank, imageId, NewOrderSignalActions.RemoveImage);
} else if (newVoteCount >= newOrderConfig.limits.maxKnightVotes) {
} else if (newVoteCount >= newOrderConfig.limits.knightVotes) {
// Max votes reached without consensus - remove from queue
await valueInQueue!.pool.reset({ id: imageId });
await notifyQueueUpdate(valueInQueue!.rank, imageId, NewOrderSignalActions.RemoveImage);
@@ -2046,8 +2053,8 @@ export async function submitTestVote({
export async function getQueueStateForTesting(imageId?: number) {
try {
const state: {
knight: { imageId: number; voteCount: number; priority: number }[];
templar: { imageId: number; voteCount: number; priority: number }[];
knight: { imageId: number; voteCount: number; priority: number; slot: 'a' | 'b' }[];
templar: { imageId: number; voteCount: number; priority: number; slot: 'a' | 'b' }[];
totalImages: number;
} = {
knight: [],
@@ -2056,39 +2063,44 @@ export async function getQueueStateForTesting(imageId?: number) {
};
if (imageId) {
// Check specific image across all queues
// Check specific image across all queues and both slots
for (const rankType of [NewOrderRankType.Knight]) {
for (let priority = 1; priority <= 3; priority++) {
const pool = poolCounters[rankType][priority - 1];
const count = await pool.getCount(imageId);
if (count !== null) {
const queueData = { imageId, voteCount: count, priority };
for (const slot of ['a', 'b'] as const) {
for (let priority = 1; priority <= 3; priority++) {
const pool = poolCounters[rankType][slot][priority - 1];
const count = await pool.getCount(imageId);
if (count !== null) {
const queueData = { imageId, voteCount: count, priority, slot };
if (rankType === NewOrderRankType.Knight) {
state.knight.push(queueData);
} else {
state.templar.push(queueData);
if (rankType === NewOrderRankType.Knight) {
state.knight.push(queueData);
} else {
state.templar.push(queueData);
}
}
}
}
}
} else {
// Get all images from all queues
// Get all images from all queues and both slots
for (const rankType of [NewOrderRankType.Knight]) {
for (let priority = 1; priority <= 3; priority++) {
const pool = poolCounters[rankType][priority - 1];
const allValues = await pool.getAll({ withCount: true });
for (const slot of ['a', 'b'] as const) {
for (let priority = 1; priority <= 3; priority++) {
const pool = poolCounters[rankType][slot][priority - 1];
const allValues = await pool.getAll({ withCount: true });
const images = allValues.map(({ value, score }) => ({
imageId: Number(value),
voteCount: score,
priority,
}));
const images = allValues.map(({ value, score }: { value: string; score: number }) => ({
imageId: Number(value),
voteCount: score,
priority,
slot,
}));
if (rankType === NewOrderRankType.Knight) {
state.knight.push(...images);
} else {
state.templar.push(...images);
if (rankType === NewOrderRankType.Knight) {
state.knight.push(...images);
} else {
state.templar.push(...images);
}
}
}
}
@@ -2220,10 +2232,12 @@ export async function getVoteDetailsForTesting(imageId: number) {
*/
export async function resetImageVotesForTesting(imageId: number) {
try {
// Remove from all queues
// Remove from all queues and both slots
for (const rankType of [NewOrderRankType.Knight]) {
for (let priority = 1; priority <= 3; priority++) {
await poolCounters[rankType][priority - 1].reset({ id: imageId });
for (const slot of ['a', 'b'] as const) {
for (let priority = 1; priority <= 3; priority++) {
await poolCounters[rankType][slot][priority - 1].reset({ id: imageId });
}
}
}
+4 -2
View File
@@ -5796,8 +5796,10 @@ export async function queueImageSearchIndexUpdate({
await thumbnailCache.bust(ids);
// Remove the image from the knights of new order pool counters
await Promise.all([
...poolCounters.Knight.map((queue) => queue.reset({ id: ids })),
...poolCounters.Templar.map((queue) => queue.reset({ id: ids })),
...poolCounters.Knight.a.map((queue) => queue.reset({ id: ids })),
...poolCounters.Knight.b.map((queue) => queue.reset({ id: ids })),
...poolCounters.Templar.a.map((queue) => queue.reset({ id: ids })),
...poolCounters.Templar.b.map((queue) => queue.reset({ id: ids })),
]);
}
}