feat(feed): serve hideChallenges and bare offset cursors from the feed

hideChallenges is already resolved into an excluded tag before the feed
mapper runs, so the feed can serve it as is. A cursor that is a bare offset
(no entry timestamp) pages the same way the search path reads it. Cursors
that still fail to parse are logged, throttled, so the remaining shapes can
be identified.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Koen
2026-09-18 22:24:40 +00:00
parent ad3134ccc6
commit 0aba1a84ec
4 changed files with 40 additions and 4 deletions
@@ -119,6 +119,14 @@ describe('getAllImagesIndex with feed-service-primary', () => {
expect(fetchFeedPrimary).not.toHaveBeenCalled();
});
it('hides challenge entries in the feed by excluding the challenge tag', async () => {
primaryOn.mockReturnValue(true);
fetchFeedPrimary.mockResolvedValue({ status: 200, ms: 3, ids: [], nextCursor: undefined });
await getAllImagesIndex({ ...request(), hideChallenges: true });
const query = new URLSearchParams(fetchFeedPrimary.mock.calls[0][0] as string);
expect(query.get('excludedTags')?.split(',')).toContain('676575');
});
it('scopes the feed to the new-creator board', async () => {
primaryOn.mockReturnValue(true);
newCreatorIds.mockReturnValue([11, 12]);
@@ -83,6 +83,15 @@ describe('mapSearchInputToFeedQuery', () => {
expect(q('Oldest').get('before')).toBeNull();
});
it('pages a bare offset cursor like an offset|entry one, without freezing the set', () => {
for (const cursor of ['400', 400]) {
const m = mapSearchInputToFeedQuery({ ...base, sort: 'Newest', cursor });
const q = new URLSearchParams(m.ok ? m.query : '');
expect(q.get('offset')).toBe('400');
expect(q.get('before')).toBeNull();
}
});
it('names the first thing it cannot express', () => {
const reason = (i: Record<string, unknown>) => {
const m = mapSearchInputToFeedQuery({ ...base, ...i });
@@ -1,4 +1,5 @@
import { env } from '~/env/server';
import { logToAxiom } from '~/server/logging/client';
import { registerCounterWithLabels, registerHistogram } from '~/server/prom/client';
import type { CapturableSearchInput } from '~/server/services/feed-request-capture.service';
import {
@@ -26,6 +27,23 @@ export function reasonLabel(reason: string) {
const count = (outcome: string, reason = '') =>
requestCounter.inc({ outcome, reason: reasonLabel(reason) });
const CURSOR_LOG_INTERVAL_MS = 10_000;
let cursorLoggedAt = 0;
function logUnparsedCursor(cursor: unknown) {
const now = Date.now();
if (now - cursorLoggedAt < CURSOR_LOG_INTERVAL_MS) return;
cursorLoggedAt = now;
logToAxiom(
{
type: 'warning',
name: 'feed-primary-cursor-unparsed',
cursorType: typeof cursor,
cursor: String(cursor).slice(0, 80),
},
'civitai-prod'
).catch(() => undefined);
}
const hydrateDuration = registerHistogram({
name: 'feed_primary_hydrate_duration_seconds',
help: 'Time to load the rows of a feed-served page from Postgres',
@@ -115,6 +133,7 @@ export async function serveFromFeed<T extends { id: number }>(
const mapping = mapSearchInputToFeedQuery(input, 'primary');
if (!mapping.ok) {
count(mapping.reason === DEEP_OFFSET ? 'rejected' : 'unmapped', mapping.reason);
if (mapping.reason === 'cursor:unparsed') logUnparsedCursor(input.cursor);
return { ok: false, reason: mapping.reason };
}
let answer: FeedAnswer;
+4 -4
View File
@@ -94,7 +94,6 @@ const UNSUPPORTED_FLAGS = [
'requiringMeta',
'hideAutoResources',
'hideManualResources',
'hideChallenges',
'pending',
'publishedOnly',
'remixesOnly',
@@ -158,11 +157,12 @@ export function mapSearchInputToFeedQuery(
if (feedCursor) {
if (mode !== 'primary') return skip('cursor:feed');
} else if (typeof cursor === 'string' && cursor) {
const m = /^(\d{1,12})\|(\d{1,15})$/.exec(cursor);
const m = /^(\d{1,12})(?:\|(\d{1,15}))?$/.exec(cursor);
if (!m) return skip('cursor:unparsed');
offset = Number(m[1]);
before = Math.floor(Number(m[2]) / 60_000) * 60_000;
} else if (cursor) return skip('cursor:unparsed');
if (m[2]) before = Math.floor(Number(m[2]) / 60_000) * 60_000;
} else if (typeof cursor === 'number' && Number.isInteger(cursor) && cursor >= 0) offset = cursor;
else if (cursor) return skip('cursor:unparsed');
if (typeof input.offset === 'number' && input.offset > 0) offset = Math.max(offset, input.offset);
const sort = SORTS[String(input.sort)];