diff --git a/docs/developer/http-api-contract.md b/docs/developer/http-api-contract.md
index 79cdc045..6aa8f63c 100644
--- a/docs/developer/http-api-contract.md
+++ b/docs/developer/http-api-contract.md
@@ -174,6 +174,20 @@ Self-hosted only; requires a **global administrator**.
| `GET` | `/api/admin/backup/info` | `{ attachments_available, attachment_files, attachment_bytes }`: what a backup with files would contain (`attachments_available: false` when attachment storage is not configured). **403** for non-admins, **410** like below. |
| `POST` | `/api/admin/backup` | No body; needs the CSRF header. Query `attachments` (default `true`). Runs `pg_dump --no-owner --no-privileges` against `DATABASE_URL` and returns **200** with `Content-Type: application/gzip` and `Content-Disposition: attachment; filename="marreq-backup__.tar.gz"`: a tar.gz with `marreq-backup_<…>/database.sql` (plain SQL), `attachments/` (the attachment store, unless `attachments=false` or storage is not configured) and `manifest.json` (`format: "marreq.backup.v1"`, `includes_attachments`, `attachment_files`, `attachment_bytes`). Before issue #341 this was a `.sql.gz`. **403** for non-admins; **410** when the deployment mode disallows backups (`allows_database_backup: false` in `GET /api/meta/deployment`, i.e. cloud); **500** with the `pg_dump` error summary if the dump fails. Success and failure are recorded as `EXPORT` audit rows. The `pg_dump` binary can be overridden with `MARREQ_PG_DUMP`. The Compose nginx allows up to 15 minutes for this route. |
+## Admin application cache
+
+Instance-wide (the in-memory cache of the running process); served by both `marreq-server` and `marreq-cloud`. The SPA page is Administration → Cache. Every route except `health` requires a **global administrator**; other authenticated users receive **403**. The `POST` routes take no body and need the CSRF header.
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/api/cache/stats` | `{ total_entries, active_entries, expired_entries, hits, misses, total_requests, hit_rate, miss_rate, average_access_time_ns, total_access_time_ns, last_cleanup, cache_size_bytes }`. `hit_rate` and `miss_rate` are fractions in 0..1. This is the source the SPA uses. |
+| `GET` | `/api/cache/recommendations` | `{ recommendations: string[], priority: "high" \| "medium" \| "low", analysis_timestamp }`. |
+| `POST` | `/api/cache/clear` | Drops every entry. Response `{ message, timestamp }`. |
+| `POST` | `/api/cache/cleanup` | Drops only the expired entries. Response `{ message, cleaned_entries, timestamp }`. |
+| `POST` | `/api/cache/reset-counters` | Zeroes the hit/miss counters; entries are kept. Response `{ message, timestamp }`. |
+| `GET` | `/api/cache/performance` | Derived metrics (JSON object). Not used by the SPA: `hit_rate_percentage` is the 0..1 fraction rounded to two decimals, not a percentage, and `memory_usage_mb` is not in megabytes. Use `stats` instead. |
+| `GET` | `/api/cache/health` | **Public.** `{ status, total_entries, active_entries, expired_entries, memory_usage, cleanup_needed, last_cleanup }`. `memory_usage` is the entry count, and `last_cleanup` is the time of the request. |
+
## User management
Instance-wide. All routes require a **global administrator** (`users.is_admin`); other authenticated users receive **403**. Mutating routes need the CSRF header like any other SPA write.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f19e0cf2..86e088df 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -23,6 +23,7 @@ import AdminPage from '@/pages/AdminPage';
import SystemLogsPage from '@/pages/SystemLogsPage';
import LogAnalyticsPage from '@/pages/LogAnalyticsPage';
import BackupPage from '@/pages/BackupPage';
+import CachePage from '@/pages/CachePage';
import CreateRequirementPage from '@/pages/CreateRequirementPage';
import CreateVerificationPage from '@/pages/CreateVerificationPage';
import DashboardPage from '@/pages/DashboardPage';
@@ -134,6 +135,7 @@ export default function App() {
} />
} />
} />
+ } />
{/* Project workspace: /:projectSlug/... */}
}>
diff --git a/frontend/src/api/__tests__/cache.test.ts b/frontend/src/api/__tests__/cache.test.ts
new file mode 100644
index 00000000..9787ca37
--- /dev/null
+++ b/frontend/src/api/__tests__/cache.test.ts
@@ -0,0 +1,79 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { CSRF, itSendsEachRequest, stubFetch } from '@/test/apiHarness';
+import { ApiError } from '../transport';
+import {
+ clearCache,
+ cleanupCache,
+ getCacheRecommendations,
+ getCacheStats,
+ resetCacheCounters,
+} from '../cache';
+
+const stats = {
+ total_entries: 10,
+ active_entries: 8,
+ expired_entries: 2,
+ hits: 30,
+ misses: 10,
+ total_requests: 40,
+ hit_rate: 0.75,
+ miss_rate: 0.25,
+ average_access_time_ns: 1200,
+ total_access_time_ns: 48000,
+ last_cleanup: '2026-10-29T08:00:00Z',
+ cache_size_bytes: 4096,
+};
+
+describe('cache API', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ itSendsEachRequest([
+ {
+ name: 'getCacheStats',
+ call: () => getCacheStats(),
+ method: 'GET',
+ url: '/api/cache/stats',
+ response: { json: stats },
+ returns: stats,
+ },
+ {
+ name: 'getCacheRecommendations',
+ call: () => getCacheRecommendations(),
+ method: 'GET',
+ url: '/api/cache/recommendations',
+ response: {
+ json: { recommendations: ['Run cleanup'], priority: 'low', analysis_timestamp: '2026-10-29T08:00:00Z' },
+ },
+ returns: { recommendations: ['Run cleanup'], priority: 'low', analysis_timestamp: '2026-10-29T08:00:00Z' },
+ },
+ {
+ name: 'clearCache',
+ call: () => clearCache(CSRF),
+ method: 'POST',
+ url: '/api/cache/clear',
+ response: { json: { message: 'Cache cleared successfully', timestamp: 't' } },
+ returns: { message: 'Cache cleared successfully', timestamp: 't' },
+ },
+ {
+ name: 'cleanupCache returns the number of removed entries',
+ call: () => cleanupCache(CSRF),
+ method: 'POST',
+ url: '/api/cache/cleanup',
+ response: { json: { message: 'done', cleaned_entries: 2, timestamp: 't' } },
+ returns: { message: 'done', cleaned_entries: 2, timestamp: 't' },
+ },
+ {
+ name: 'resetCacheCounters',
+ call: () => resetCacheCounters(CSRF),
+ method: 'POST',
+ url: '/api/cache/reset-counters',
+ response: { json: { message: 'reset', timestamp: 't' } },
+ returns: { message: 'reset', timestamp: 't' },
+ },
+ ]);
+
+ it('rejects with an ApiError for non-administrators', async () => {
+ stubFetch({ status: 403, json: { message: 'forbidden' } });
+ await expect(getCacheStats()).rejects.toBeInstanceOf(ApiError);
+ });
+});
diff --git a/frontend/src/api/cache.ts b/frontend/src/api/cache.ts
new file mode 100644
index 00000000..ce4a68e1
--- /dev/null
+++ b/frontend/src/api/cache.ts
@@ -0,0 +1,64 @@
+import { fetchJson } from './transport';
+
+/** `GET /api/cache/stats` (site administrators only). Rates are fractions in 0..1. */
+export type CacheStats = {
+ total_entries: number;
+ active_entries: number;
+ expired_entries: number;
+ hits: number;
+ misses: number;
+ total_requests: number;
+ hit_rate: number;
+ miss_rate: number;
+ average_access_time_ns: number;
+ total_access_time_ns: number;
+ /** RFC 3339 timestamp. */
+ last_cleanup: string;
+ cache_size_bytes: number;
+};
+
+export type CacheRecommendationPriority = 'high' | 'medium' | 'low';
+
+export type CacheRecommendations = {
+ recommendations: string[];
+ priority: CacheRecommendationPriority;
+ /** RFC 3339 timestamp. */
+ analysis_timestamp: string;
+};
+
+/** Answer of the write endpoints; `cleaned_entries` only comes with a cleanup. */
+export type CacheActionResult = {
+ message: string;
+ timestamp: string;
+ cleaned_entries?: number;
+};
+
+export async function getCacheStats(): Promise {
+ return fetchJson('/api/cache/stats');
+}
+
+export async function getCacheRecommendations(): Promise {
+ return fetchJson('/api/cache/recommendations');
+}
+
+function cachePost(path: string, csrfToken: string): Promise {
+ return fetchJson(path, {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': csrfToken },
+ });
+}
+
+/** Drops every cached entry; the next requests rebuild it from the database. */
+export function clearCache(csrfToken: string): Promise {
+ return cachePost('/api/cache/clear', csrfToken);
+}
+
+/** Drops only the expired entries. */
+export function cleanupCache(csrfToken: string): Promise {
+ return cachePost('/api/cache/cleanup', csrfToken);
+}
+
+/** Zeroes the hit/miss counters without touching the cached entries. */
+export function resetCacheCounters(csrfToken: string): Promise {
+ return cachePost('/api/cache/reset-counters', csrfToken);
+}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index cbc959cd..3f20603f 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -17,6 +17,7 @@ export * from './meta';
export * from './imports';
export * from './exports';
export * from './logs';
+export * from './cache';
export * from './attachments';
export * from './verificationControl';
export * from './reports';
diff --git a/frontend/src/pages/CachePage.tsx b/frontend/src/pages/CachePage.tsx
new file mode 100644
index 00000000..394ebdb2
--- /dev/null
+++ b/frontend/src/pages/CachePage.tsx
@@ -0,0 +1,290 @@
+import { useCallback, useEffect, useState } from 'react';
+import {
+ type CacheActionResult,
+ type CacheRecommendationPriority,
+ type CacheRecommendations,
+ type CacheStats,
+ cleanupCache,
+ clearCache,
+ getCacheRecommendations,
+ getCacheStats,
+ getCsrfToken,
+ listUsersOptional,
+ resetCacheCounters,
+} from '@/api/client';
+import StitchPageHeader from '@/components/StitchPageHeader';
+import { useDashboard } from '@/context/DashboardContext';
+import { btnDanger, btnPrimary } from '@/pages/catalog/catalogUi';
+import { ADMIN_BREADCRUMB } from '@/pages/admin/adminArea';
+import { formatBytes } from '@/utils/formatBytes';
+import { parseUser } from '@/utils/parseUser';
+
+const CLEAR_CONFIRM =
+ 'Clear all cache entries? This will temporarily slow down the application until the cache is rebuilt.';
+const RESET_CONFIRM = 'Reset the cache hit/miss counters? The cached entries are kept.';
+
+const PRIORITY_BADGE: Record = {
+ high: 'border-red-500/30 bg-red-500/10 text-red-800 dark:text-red-100',
+ medium: 'border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-100',
+ low: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-900 dark:text-emerald-100',
+};
+
+const cardClass = 'rounded-xl border border-stitch-border bg-stitch-surface p-5 shadow-stitch space-y-3';
+const cardTitleClass = 'text-sm font-bold text-stitch-accent uppercase tracking-wide';
+const btnSecondary =
+ 'inline-flex items-center justify-center rounded-md border border-stitch-border px-4 py-2 text-xs font-bold uppercase tracking-wider text-stitch-accent hover:bg-stitch-higher disabled:opacity-50';
+
+/** The API reports the average access time in nanoseconds. */
+function formatDuration(ns: number): string {
+ if (!Number.isFinite(ns) || ns < 0) return '—';
+ if (ns < 1_000) return `${Math.round(ns)} ns`;
+ if (ns < 1_000_000) return `${(ns / 1_000).toFixed(1)} µs`;
+ return `${(ns / 1_000_000).toFixed(2)} ms`;
+}
+
+/** `hit_rate` is a fraction in 0..1, not a percentage. */
+function formatRate(rate: number): string {
+ return Number.isFinite(rate) ? `${(rate * 100).toFixed(1)}%` : '—';
+}
+
+function formatTimestamp(value: string): string {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
+}
+
+function Metric({ label, value }: { label: string; value: string | number }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
+
+export default function CachePage() {
+ const { dashboard, csrfToken } = useDashboard();
+ const me = parseUser(dashboard?.user);
+
+ const [allowed, setAllowed] = useState(null);
+ const [stats, setStats] = useState(null);
+ const [advice, setAdvice] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ const load = useCallback(async () => {
+ const [nextStats, nextAdvice] = await Promise.all([
+ getCacheStats(),
+ // The recommendations are advice only; the page still works without them.
+ getCacheRecommendations().catch(() => null),
+ ]);
+ setStats(nextStats);
+ setAdvice(nextAdvice);
+ }, []);
+
+ useEffect(() => {
+ let alive = true;
+ // `/api/users` is admin-only, so it doubles as the access check (same as Backup).
+ listUsersOptional().then((users) => {
+ if (!alive) return;
+ setAllowed(users !== null);
+ if (users === null) return;
+ load().catch((e) => {
+ if (alive) setError(e instanceof Error ? e.message : 'Could not load the cache statistics');
+ });
+ });
+ return () => {
+ alive = false;
+ };
+ }, [load]);
+
+ async function run(
+ action: (csrf: string) => Promise,
+ describe: (result: CacheActionResult) => string,
+ ) {
+ setBusy(true);
+ setError(null);
+ setNotice(null);
+ try {
+ const result = await action(csrfToken ?? (await getCsrfToken()));
+ setNotice(describe(result));
+ await load();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Cache action failed');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function onRefresh() {
+ setBusy(true);
+ setError(null);
+ setNotice(null);
+ load()
+ .catch((e) => setError(e instanceof Error ? e.message : 'Could not load the cache statistics'))
+ .finally(() => setBusy(false));
+ }
+
+ function onCleanup() {
+ void run(cleanupCache, (r) => {
+ const n = r.cleaned_entries ?? 0;
+ return `Removed ${n} expired ${n === 1 ? 'entry' : 'entries'}.`;
+ });
+ }
+
+ function onClear() {
+ if (!window.confirm(CLEAR_CONFIRM)) return;
+ void run(clearCache, () => 'Cache cleared.');
+ }
+
+ function onResetCounters() {
+ if (!window.confirm(RESET_CONFIRM)) return;
+ void run(resetCacheCounters, () => 'Cache counters reset.');
+ }
+
+ if (allowed === null) {
+ return (
+
+ Loading…
+
+ );
+ }
+
+ if (!allowed) {
+ return (
+
+
+
+
+ lock
+
+
Access denied
+
+ Managing the application cache requires a global administrator account. You are signed
+ in as {me?.username ?? '?'} .
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+
+
+
+
Entries
+ {stats ? (
+
+
+
+
+
+
+ ) : (
+
No statistics available.
+ )}
+
+
+
+
Performance
+ {stats ? (
+ <>
+
+
+
+
+
+
+
+
+ Last cleanup: {formatTimestamp(stats.last_cleanup)}
+
+ >
+ ) : (
+
No statistics available.
+ )}
+
+
+
+
+
Recommendations
+ {advice ? (
+
+ {advice.priority}
+
+ ) : null}
+
+ {advice && advice.recommendations.length > 0 ? (
+
+ {advice.recommendations.map((text) => (
+ {text}
+ ))}
+
+ ) : (
+
No recommendations.
+ )}
+ {advice ? (
+
+ Analysis performed at {formatTimestamp(advice.analysis_timestamp)}
+
+ ) : null}
+
+
+
+
Actions
+
+ Clean up removes only expired entries. Clear all drops every entry, so the next
+ requests rebuild the cache from the database. Resetting the counters keeps the
+ entries.
+
+
+
+ Refresh
+
+
+ Clean up expired
+
+
+ Reset counters
+
+
+ Clear all
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/__tests__/CachePage.test.tsx b/frontend/src/pages/__tests__/CachePage.test.tsx
new file mode 100644
index 00000000..4153869e
--- /dev/null
+++ b/frontend/src/pages/__tests__/CachePage.test.tsx
@@ -0,0 +1,178 @@
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import CachePage from '../CachePage';
+
+const mocks = vi.hoisted(() => ({
+ listUsersOptional: vi.fn(),
+ getCsrfToken: vi.fn(),
+ getCacheStats: vi.fn(),
+ getCacheRecommendations: vi.fn(),
+ clearCache: vi.fn(),
+ cleanupCache: vi.fn(),
+ resetCacheCounters: vi.fn(),
+}));
+
+vi.mock('@/api/client', () => mocks);
+
+vi.mock('@/context/DashboardContext', () => ({
+ useDashboard: () => ({
+ csrfToken: 'csrf-token',
+ dashboard: { user: { id: 1, username: 'alice', name: 'Alice', is_admin: true } },
+ }),
+}));
+
+const stats = {
+ total_entries: 10,
+ active_entries: 8,
+ expired_entries: 2,
+ hits: 30,
+ misses: 10,
+ total_requests: 40,
+ // A fraction, not a percentage: the page must show 75.0%, not 0.8%.
+ hit_rate: 0.75,
+ miss_rate: 0.25,
+ average_access_time_ns: 1200,
+ total_access_time_ns: 48000,
+ last_cleanup: '2026-10-29T08:00:00Z',
+ cache_size_bytes: 4096,
+};
+
+const advice = {
+ recommendations: ['Run a cache cleanup to drop expired entries'],
+ priority: 'medium',
+ analysis_timestamp: '2026-10-29T08:00:05Z',
+};
+
+function renderPage() {
+ return render(
+
+
+ } />
+
+ ,
+ );
+}
+
+describe('CachePage', () => {
+ let confirm: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ mocks.listUsersOptional.mockResolvedValue([{ id: 1, username: 'alice' }]);
+ mocks.getCsrfToken.mockResolvedValue('fetched-csrf');
+ mocks.getCacheStats.mockResolvedValue(stats);
+ mocks.getCacheRecommendations.mockResolvedValue(advice);
+ mocks.clearCache.mockResolvedValue({ message: 'Cache cleared successfully', timestamp: 't' });
+ mocks.cleanupCache.mockResolvedValue({ message: 'done', cleaned_entries: 2, timestamp: 't' });
+ mocks.resetCacheCounters.mockResolvedValue({ message: 'reset', timestamp: 't' });
+ });
+
+ afterEach(() => confirm.mockRestore());
+
+ it('shows access denied for non-admins without calling the cache API', async () => {
+ mocks.listUsersOptional.mockResolvedValue(null);
+ renderPage();
+ expect(await screen.findByText('Access denied')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Clear all' })).not.toBeInTheDocument();
+ expect(mocks.getCacheStats).not.toHaveBeenCalled();
+ expect(mocks.getCacheRecommendations).not.toHaveBeenCalled();
+ });
+
+ it('shows the entries, the performance figures and the recommendations', async () => {
+ renderPage();
+ const entries = await screen.findByTestId('cache-entries');
+ expect(within(entries).getByText('Total').nextSibling).toHaveTextContent('10');
+ expect(within(entries).getByText('Expired').nextSibling).toHaveTextContent('2');
+ expect(within(entries).getByText('Size').nextSibling).toHaveTextContent('4 KB');
+
+ const performance = screen.getByTestId('cache-performance');
+ expect(within(performance).getByText('Hit rate').nextSibling).toHaveTextContent('75.0%');
+ expect(within(performance).getByText('Requests').nextSibling).toHaveTextContent('40');
+ expect(within(performance).getByText('Avg access time').nextSibling).toHaveTextContent('1.2 µs');
+
+ expect(screen.getByText('Run a cache cleanup to drop expired entries')).toBeInTheDocument();
+ expect(screen.getByTestId('cache-priority')).toHaveTextContent('medium');
+ });
+
+ it('still shows the statistics when the recommendations cannot be loaded', async () => {
+ mocks.getCacheRecommendations.mockRejectedValue(new Error('boom'));
+ renderPage();
+ expect(await screen.findByTestId('cache-entries')).toBeInTheDocument();
+ expect(screen.getByText('No recommendations.')).toBeInTheDocument();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('shows an error when the statistics cannot be loaded', async () => {
+ mocks.getCacheStats.mockRejectedValue(new Error('cache unavailable'));
+ renderPage();
+ expect(await screen.findByRole('alert')).toHaveTextContent('cache unavailable');
+ });
+
+ it('cleans up expired entries without asking and reports how many were removed', async () => {
+ renderPage();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Clean up expired' }));
+
+ expect(mocks.cleanupCache).toHaveBeenCalledWith('csrf-token');
+ expect(confirm).not.toHaveBeenCalled();
+ expect(await screen.findByRole('status')).toHaveTextContent('Removed 2 expired entries.');
+ expect(mocks.getCacheStats).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not clear the cache when the confirmation is declined', async () => {
+ confirm.mockReturnValue(false);
+ renderPage();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Clear all' }));
+
+ expect(confirm).toHaveBeenCalledWith(expect.stringContaining('temporarily slow down'));
+ expect(mocks.clearCache).not.toHaveBeenCalled();
+ expect(mocks.getCacheStats).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears the cache after confirmation and reloads the statistics', async () => {
+ renderPage();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Clear all' }));
+
+ expect(mocks.clearCache).toHaveBeenCalledWith('csrf-token');
+ expect(await screen.findByRole('status')).toHaveTextContent('Cache cleared.');
+ await waitFor(() => expect(mocks.getCacheStats).toHaveBeenCalledTimes(2));
+ });
+
+ it('resets the counters only after confirmation', async () => {
+ confirm.mockReturnValueOnce(false);
+ renderPage();
+ const user = userEvent.setup();
+ const button = await screen.findByRole('button', { name: 'Reset counters' });
+
+ await user.click(button);
+ expect(mocks.resetCacheCounters).not.toHaveBeenCalled();
+
+ await user.click(button);
+ expect(mocks.resetCacheCounters).toHaveBeenCalledWith('csrf-token');
+ expect(await screen.findByRole('status')).toHaveTextContent('Cache counters reset.');
+ });
+
+ it('shows the backend error when an action fails and keeps the statistics', async () => {
+ mocks.clearCache.mockRejectedValue(new Error('forbidden'));
+ renderPage();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Clear all' }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('forbidden');
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ expect(screen.getByTestId('cache-entries')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Clear all' })).toBeEnabled();
+ });
+
+ it('refreshes the statistics on demand', async () => {
+ renderPage();
+ const user = userEvent.setup();
+ await user.click(await screen.findByRole('button', { name: 'Refresh' }));
+ await waitFor(() => expect(mocks.getCacheStats).toHaveBeenCalledTimes(2));
+ });
+});
diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx
index 10753921..c1fec0eb 100644
--- a/frontend/src/pages/admin/AdminLayout.tsx
+++ b/frontend/src/pages/admin/AdminLayout.tsx
@@ -47,6 +47,7 @@ export default function AdminLayout() {
{ to: `${ADMIN_BASE}/logs`, label: 'System logs', icon: 'history', end: true },
{ to: `${ADMIN_BASE}/logs/analytics`, label: 'Log analytics', icon: 'monitoring' },
{ to: `${ADMIN_BASE}/backup`, label: 'Backup', icon: 'backup', hidden: !backupAllowed },
+ { to: `${ADMIN_BASE}/cache`, label: 'Cache', icon: 'cached' },
];
return (
diff --git a/frontend/src/pages/admin/__tests__/AdminLayout.test.tsx b/frontend/src/pages/admin/__tests__/AdminLayout.test.tsx
index f27cf1ef..1608ef6a 100644
--- a/frontend/src/pages/admin/__tests__/AdminLayout.test.tsx
+++ b/frontend/src/pages/admin/__tests__/AdminLayout.test.tsx
@@ -21,6 +21,7 @@ function renderAt(entry: string | { pathname: string; state?: unknown }) {
logs page } />
analytics page} />
backup page} />
+ cache page} />
} />
@@ -42,7 +43,7 @@ describe('AdminLayout', () => {
renderAt('/admin/logs');
expect(screen.getByText('logs page')).toBeInTheDocument();
const nav = screen.getByRole('navigation', { name: 'Administration sections' });
- await waitFor(() => expect(nav.querySelectorAll('a')).toHaveLength(4));
+ await waitFor(() => expect(nav.querySelectorAll('a')).toHaveLength(5));
expect(screen.getByRole('link', { name: /system logs/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: 'Users' })).not.toHaveAttribute('aria-current');
});
diff --git a/marreq-core/src/repository/cache/repository.rs b/marreq-core/src/repository/cache/repository.rs
index 14949bba..e2b2430e 100644
--- a/marreq-core/src/repository/cache/repository.rs
+++ b/marreq-core/src/repository/cache/repository.rs
@@ -23,8 +23,6 @@ pub struct Cache {
pub(super) hits: Arc,
pub(super) misses: Arc,
pub(super) total_access_time: Arc,
- pub(super) active_entries: Arc,
- pub(super) expired_entries: Arc,
pub(super) last_cleanup: Arc>>,
}
@@ -43,8 +41,6 @@ impl Cache {
hits: Arc::new(AtomicU64::new(0)),
misses: Arc::new(AtomicU64::new(0)),
total_access_time: Arc::new(AtomicU64::new(0)),
- active_entries: Arc::new(AtomicU64::new(0)),
- expired_entries: Arc::new(AtomicU64::new(0)),
last_cleanup: Arc::new(Mutex::new(chrono::Utc::now())),
}
}
@@ -83,9 +79,6 @@ impl Cache {
None
}
Status::Expired => {
- // Update counters: move from active to expired before removal
- self.active_entries.fetch_sub(1, Ordering::Relaxed);
- self.expired_entries.fetch_add(1, Ordering::Relaxed);
self.misses.fetch_add(1, Ordering::Relaxed);
self.remove(key);
None
@@ -110,54 +103,25 @@ impl Cache {
expires_at: Instant::now() + ttl,
};
- let mut data = self.data.write().unwrap();
- // Check if this key already exists to update counters correctly
- let existed = data.contains_key(key);
- data.insert(key.to_string(), entry);
-
- // Update active entries counter
- if !existed {
- self.active_entries.fetch_add(1, Ordering::Relaxed);
- }
+ self.data.write().unwrap().insert(key.to_string(), entry);
}
/// Remove a key from cache
pub fn remove(&self, key: &str) {
- let mut data = self.data.write().unwrap();
- if let Some(entry) = data.remove(key) {
- let now = Instant::now();
-
- // Always decrement active count first
- self.active_entries
- .try_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1))
- .ok();
-
- // If expired, also decrement expired count
- if entry.expires_at <= now {
- self.expired_entries
- .try_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1))
- .ok();
- }
- }
+ self.data.write().unwrap().remove(key);
}
/// Clear all cache entries
pub fn clear(&self) {
- let mut data = self.data.write().unwrap();
- data.clear();
-
- // Reset entry counters
- self.active_entries.store(0, Ordering::Relaxed);
- self.expired_entries.store(0, Ordering::Relaxed);
+ self.data.write().unwrap().clear();
}
- /// Reset performance counters
+ /// Reset the hit/miss/access-time counters. The cached entries stay, and
+ /// so do the entry counts, which are derived from them.
pub fn reset_counters(&self) {
self.hits.store(0, Ordering::Relaxed);
self.misses.store(0, Ordering::Relaxed);
self.total_access_time.store(0, Ordering::Relaxed);
- self.active_entries.store(0, Ordering::Relaxed);
- self.expired_entries.store(0, Ordering::Relaxed);
}
/// Clean up expired entries and return count of cleaned entries
@@ -166,22 +130,10 @@ impl Cache {
let now = Instant::now();
let initial_count = data.len();
- // Count expired entries before removal
- let expired_count = data
- .values()
- .filter(|entry| entry.expires_at <= now)
- .count();
-
data.retain(|_, entry| entry.expires_at > now);
let cleaned_count = initial_count - data.len();
- // Update counters for cleaned entries
- if expired_count > 0 {
- self.expired_entries
- .fetch_sub(expired_count as u64, Ordering::Relaxed);
- }
-
// Update last cleanup time
let mut last_cleanup = self.last_cleanup.lock().unwrap();
*last_cleanup = chrono::Utc::now();
@@ -189,22 +141,6 @@ impl Cache {
cleaned_count
}
- /// Sync counters with actual data (useful for maintenance and debugging)
- pub fn sync_counters(&self) {
- let data = self.data.read().unwrap();
- let now = Instant::now();
-
- let actual_active = data.values().filter(|entry| entry.expires_at > now).count() as u64;
- let actual_expired = data
- .values()
- .filter(|entry| entry.expires_at <= now)
- .count() as u64;
-
- self.active_entries.store(actual_active, Ordering::Relaxed);
- self.expired_entries
- .store(actual_expired, Ordering::Relaxed);
- }
-
/// Invalidate all project-related cache entries
pub fn invalidate_project(&self, project_id: i32) {
self.remove(&keys::Requirements::by_project(project_id));
diff --git a/marreq-core/src/repository/cache/stats.rs b/marreq-core/src/repository/cache/stats.rs
index 0d71e2bd..6a064bae 100644
--- a/marreq-core/src/repository/cache/stats.rs
+++ b/marreq-core/src/repository/cache/stats.rs
@@ -6,7 +6,7 @@ use super::repository::Cache;
use chrono;
use serde::{Deserialize, Serialize};
use serde_json::json;
-use std::time::Duration;
+use std::time::{Duration, Instant};
/// Cache statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -28,7 +28,7 @@ pub struct CacheStats {
impl Cache {
/// Get cache statistics
pub fn stats(&self) -> CacheStats {
- // Use atomic counters for O(1) performance instead of O(n) calculations
+ // Hit/miss/access-time use atomic counters, so reading them is O(1).
let hits = self.hits.load(std::sync::atomic::Ordering::Relaxed);
let misses = self.misses.load(std::sync::atomic::Ordering::Relaxed);
let total_requests = hits + misses;
@@ -47,24 +47,29 @@ impl Cache {
)
};
- let active_entries = self
- .active_entries
- .load(std::sync::atomic::Ordering::Relaxed) as usize;
- let expired_entries = self
- .expired_entries
- .load(std::sync::atomic::Ordering::Relaxed) as usize;
+ // Count the entries from the map itself. Entries expire by the clock
+ // without anything touching them, so incrementally maintained counters
+ // drift (and used to underflow); one pass under the read lock cannot.
+ let (active_entries, expired_entries, cache_size_bytes) = {
+ let data = self.data.read().unwrap();
+ let now = Instant::now();
+ let mut active = 0usize;
+ let mut expired = 0usize;
+ let mut size = 0usize;
+ for (key, entry) in data.iter() {
+ if entry.expires_at > now {
+ active += 1;
+ } else {
+ expired += 1;
+ }
+ size += key.len() + entry.data.len();
+ }
+ (active, expired, size)
+ };
let total_entries = active_entries + expired_entries;
let last_cleanup = *self.last_cleanup.lock().unwrap();
- // Only calculate cache size when needed (this is still O(n) but less frequent)
- let cache_size_bytes = if total_entries > 0 {
- let data = self.data.read().unwrap();
- data.iter().map(|(k, e)| k.len() + e.data.len()).sum()
- } else {
- 0
- };
-
CacheStats {
total_entries,
active_entries,
@@ -245,19 +250,71 @@ mod tests {
let stats = cache.stats();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
- assert_eq!(stats.active_entries, 0);
+ assert_eq!(stats.active_entries, 1);
+ }
+
+ /// Entries that expire by the clock are never touched by `get`, so the
+ /// entry counts have to reflect what is really in the cache.
+ #[test]
+ fn test_expired_entries_are_counted_before_cleanup() {
+ let cache = Cache::new(300);
+ cache.set_with_ttl("old1", "v".to_string(), Duration::from_millis(1));
+ cache.set_with_ttl("old2", "v".to_string(), Duration::from_millis(1));
+ cache.set("keep", "v".to_string());
+ thread::sleep(Duration::from_millis(10));
+ let stats = cache.stats();
+ assert_eq!(stats.total_entries, 3);
+ assert_eq!(stats.active_entries, 1);
+ assert_eq!(stats.expired_entries, 2);
}
+ /// Regression: `cleanup` used to subtract from an unsigned counter that
+ /// had never been incremented, so `expired_entries` wrapped to u64::MAX.
#[test]
- fn test_sync_counters_updates_expired() {
+ fn test_cleanup_does_not_underflow_the_entry_counts() {
let cache = Cache::new(300);
- cache.set_with_ttl("active", "v".to_string(), Duration::from_secs(1));
- cache.set_with_ttl("expired", "v".to_string(), Duration::from_millis(1));
+ cache.set_with_ttl("old1", "v".to_string(), Duration::from_millis(1));
+ cache.set_with_ttl("old2", "v".to_string(), Duration::from_millis(1));
+ cache.set("keep", "v".to_string());
thread::sleep(Duration::from_millis(10));
- cache.sync_counters();
+ assert_eq!(cache.cleanup(), 2);
let stats = cache.stats();
+ assert_eq!(stats.total_entries, 1);
assert_eq!(stats.active_entries, 1);
- assert_eq!(stats.expired_entries, 1);
+ assert_eq!(stats.expired_entries, 0);
+ }
+
+ /// Regression: reading an expired entry after `reset_counters` used to
+ /// subtract from a zeroed counter and wrap `active_entries`.
+ #[test]
+ fn test_reading_an_expired_entry_does_not_underflow_the_entry_counts() {
+ let cache = Cache::new(300);
+ cache.set_with_ttl("old", "v".to_string(), Duration::from_millis(1));
+ cache.reset_counters();
+ thread::sleep(Duration::from_millis(10));
+ assert_eq!(cache.get("old"), None);
+ let stats = cache.stats();
+ assert_eq!(stats.total_entries, 0);
+ assert_eq!(stats.active_entries, 0);
+ assert_eq!(stats.expired_entries, 0);
+ assert_eq!(stats.misses, 1);
+ }
+
+ /// `reset_counters` only zeroes the performance counters; the cached
+ /// entries (and therefore the entry counts) stay.
+ #[test]
+ fn test_reset_counters_keeps_the_entries() {
+ let cache = Cache::new(300);
+ cache.set("a", "1".to_string());
+ assert_eq!(cache.get("a"), Some("1".to_string()));
+ cache.reset_counters();
+ let stats = cache.stats();
+ assert_eq!(stats.hits, 0);
+ assert_eq!(stats.misses, 0);
+ assert_eq!(stats.total_requests, 0);
+ assert_eq!(stats.active_entries, 1);
+ assert_eq!(stats.total_entries, 1);
+ assert_eq!(cache.get("a"), Some("1".to_string()));
}
#[test]
@@ -267,14 +324,12 @@ mod tests {
cache.set("keep", "v".to_string());
cache.set_with_ttl("gone", "v".to_string(), Duration::from_millis(1));
thread::sleep(Duration::from_millis(10));
- cache.sync_counters();
let stats = cache.get_stats();
assert_eq!(stats["total_entries"].as_u64(), Some(2));
assert_eq!(stats["active_entries"].as_u64(), Some(1));
assert_eq!(stats["expired_entries"].as_u64(), Some(1));
assert_eq!(stats["cleanup_available"].as_bool(), Some(true));
cache.cleanup();
- cache.sync_counters();
let stats = cache.get_stats();
assert_eq!(stats["total_entries"].as_u64(), Some(1));
assert_eq!(stats["active_entries"].as_u64(), Some(1));
@@ -314,12 +369,26 @@ mod tests {
cache.hits.store(1, Ordering::Relaxed);
cache.misses.store(2, Ordering::Relaxed);
cache.total_access_time.store(6_000_000, Ordering::Relaxed);
- cache.active_entries.store(10, Ordering::Relaxed);
- cache.expired_entries.store(11, Ordering::Relaxed);
+ // `get_performance` caches its own snapshot, so count the active entries
+ // instead of assuming how many there are.
+ let active = cache.stats().active_entries;
+ let add_expired = |from: usize, to: usize| {
+ for i in from..to {
+ cache.set_with_ttl(
+ &format!("old{i}"),
+ "v".to_string(),
+ Duration::from_millis(1),
+ );
+ }
+ thread::sleep(Duration::from_millis(10));
+ };
+ // expired > active -> degraded
+ add_expired(0, active + 1);
let health_degraded = cache.get_health();
assert_eq!(health_degraded["status"].as_str(), Some("degraded"));
assert!(health_degraded["cleanup_needed"].as_bool().unwrap());
- cache.expired_entries.store(25, Ordering::Relaxed);
+ // expired > 2 * active -> warning
+ add_expired(active + 1, active * 2 + 1);
let health_warning = cache.get_health();
assert_eq!(health_warning["status"].as_str(), Some("warning"));
let recs_bad = cache.get_recommendations();
@@ -354,8 +423,6 @@ mod tests {
cache.reset_counters();
cache.hits.store(10, Ordering::Relaxed);
cache.total_access_time.store(10, Ordering::Relaxed);
- cache.active_entries.store(1, Ordering::Relaxed);
- cache.expired_entries.store(0, Ordering::Relaxed);
let recs_good = cache.get_recommendations();
let recs_good_arr = recs_good["recommendations"].as_array().unwrap();
assert_eq!(recs_good_arr.len(), 1);