perf: cache admin job lists + targeted polling for audio/translation pages
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Failing after 34s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 40s
Release / Docker / runner (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled

- Add 30s Valkey cache to listScrapingTasks, listAudioJobs, listTranslationJobs
  (use listN(500) instead of unbounded listAll to cap at one request)
- Delete listAudioCache() — derive AudioCacheEntry[] from jobs in server load
- Add listBookSlugs() with 10min cache — replaces full listBooks() in translation load
- Add GET /api/admin/audio-jobs, /api/admin/translation-jobs, /api/admin/scrape-tasks
  (lightweight polling endpoints backed by the Valkey cache)
- Replace invalidateAll() interval polling in audio+translation pages with
  targeted fetch to the new endpoints (avoids re-running full server load)
This commit is contained in:
root
2026-04-06 20:33:46 +05:00
parent 75e6a870d3
commit b6904bcb6e
8 changed files with 131 additions and 33 deletions

View File

@@ -248,6 +248,14 @@ const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
const HOME_STATS_CACHE_KEY = 'home:stats';
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
const SCRAPING_TASKS_CACHE_KEY = 'admin:scraping_tasks';
const AUDIO_JOBS_CACHE_KEY = 'admin:audio_jobs';
const TRANSLATION_JOBS_CACHE_KEY = 'admin:translation_jobs';
const ADMIN_JOBS_CACHE_TTL = 30; // 30 seconds — admin views poll frequently
const BOOK_SLUGS_CACHE_KEY = 'books:slugs';
const BOOK_SLUGS_CACHE_TTL = 10 * 60; // 10 minutes — slugs change rarely
async function getAllRatings(): Promise<BookRating[]> {
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
if (cached) return cached;
@@ -272,6 +280,31 @@ export async function listBooks(): Promise<Book[]> {
return books;
}
export interface BookSlug {
slug: string;
title: string;
}
/**
* Returns only the slug and title of every book. Cheaper than listBooks() —
* used for datalist autocomplete in admin forms. Cached for 10 minutes.
*/
export async function listBookSlugs(): Promise<BookSlug[]> {
const cached = await cache.get<BookSlug[]>(BOOK_SLUGS_CACHE_KEY);
if (cached) return cached;
// Re-use full books cache if already warm — avoids a second PocketBase call.
const fullCached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
if (fullCached) {
const slugs = fullCached.map((b) => ({ slug: b.slug, title: b.title }));
await cache.set(BOOK_SLUGS_CACHE_KEY, slugs, BOOK_SLUGS_CACHE_TTL);
return slugs;
}
const items = await listAll<BookSlug>('books', '', '+title').catch(() => [] as BookSlug[]);
const slugs = items.map((b) => ({ slug: b.slug, title: b.title }));
await cache.set(BOOK_SLUGS_CACHE_KEY, slugs, BOOK_SLUGS_CACHE_TTL);
return slugs;
}
/**
* Fetch only the books whose slugs are in the given set.
* Uses PocketBase filter `slug IN (...)` — a single request regardless of how
@@ -1052,16 +1085,6 @@ export interface AudioCacheEntry {
updated: string;
}
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', '-finished');
return jobs.map((j) => ({
id: j.id,
cache_key: j.cache_key,
filename: `${j.cache_key}.mp3`,
updated: j.finished
}));
}
// ─── Scraping tasks ───────────────────────────────────────────────────────────
export interface ScrapingTask {
@@ -1081,7 +1104,11 @@ export interface ScrapingTask {
}
export async function listScrapingTasks(): Promise<ScrapingTask[]> {
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
const cached = await cache.get<ScrapingTask[]>(SCRAPING_TASKS_CACHE_KEY);
if (cached) return cached;
const tasks = await listN<ScrapingTask>('scraping_tasks', 500, '', '-started');
await cache.set(SCRAPING_TASKS_CACHE_KEY, tasks, ADMIN_JOBS_CACHE_TTL);
return tasks;
}
export async function getScrapingTask(id: string): Promise<ScrapingTask | null> {
@@ -1103,7 +1130,11 @@ export interface AudioJob {
}
export async function listAudioJobs(): Promise<AudioJob[]> {
return listAll<AudioJob>('audio_jobs', '', '-started');
const cached = await cache.get<AudioJob[]>(AUDIO_JOBS_CACHE_KEY);
if (cached) return cached;
const jobs = await listN<AudioJob>('audio_jobs', 500, '', '-started');
await cache.set(AUDIO_JOBS_CACHE_KEY, jobs, ADMIN_JOBS_CACHE_TTL);
return jobs;
}
/**
@@ -1130,7 +1161,11 @@ export interface TranslationJob {
}
export async function listTranslationJobs(): Promise<TranslationJob[]> {
return listAll<TranslationJob>('translation_jobs', '', '-started');
const cached = await cache.get<TranslationJob[]>(TRANSLATION_JOBS_CACHE_KEY);
if (cached) return cached;
const jobs = await listN<TranslationJob>('translation_jobs', 500, '', '-started');
await cache.set(TRANSLATION_JOBS_CACHE_KEY, jobs, ADMIN_JOBS_CACHE_TTL);
return jobs;
}
export async function getAudioTime(