perf: server-side caching + remove bulk status fetch from experiment page

- Add backend/src/lib/cache.js — in-process TTL Map cache with
  prefix-based invalidation; no external dependencies

- Cache /experiments/:id/calendar?field=X per (experimentId, field).
  Invalidated immediately on any DailyStatus write.

- Cache /experiments/:id/daily-statuses per (experimentId, date, analysisOnly).
  Invalidated immediately on any DailyStatus write (create/update/delete
  and analysis-summary saves all clear the relevant prefix).

- Add ?analysisOnly=true to /daily-statuses: filters rows where
  analysis_summary IS NOT NULL. Charts are the only consumer and already
  discard rows without analysis data; pre-filtering server-side avoids
  sending the full status payload (71 rows → 1 for this experiment).

- ExperimentCalendar: remove allStatuses prop, fetch calendar data
  directly via API. The component now issues one small GET per field
  selection change instead of the parent loading thousands of rows upfront.

- ExperimentDetail: pass analysisOnly:true to getDailyStatuses;
  drop allStatuses prop from ExperimentCalendar. Cold experiment page
  load no longer fetches every daily status record.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 14:03:59 -04:00
parent 52d5fca5d1
commit d2c8d69718
6 changed files with 102 additions and 22 deletions
+33
View File
@@ -0,0 +1,33 @@
// Lightweight in-process TTL cache with prefix-based invalidation.
// Single-process only — suitable for this single-container deployment.
const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes
const store = new Map(); // key → { value, expiresAt }
function set(key, value, ttlMs = DEFAULT_TTL_MS) {
store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
function get(key) {
const entry = store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
store.delete(key);
return undefined;
}
return entry.value;
}
function del(key) {
store.delete(key);
}
// Remove all keys that start with prefix — used to invalidate a whole experiment's worth of cached data.
function delByPrefix(prefix) {
for (const key of store.keys()) {
if (key.startsWith(prefix)) store.delete(key);
}
}
module.exports = { set, get, del, delByPrefix };
+26 -1
View File
@@ -1,9 +1,20 @@
const router = require('express').Router();
const { body, query } = require('express-validator');
const prisma = require('../lib/prisma');
const cache = require('../lib/cache');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
// Look up which experiment a daily status belongs to, then clear all cached data for it.
async function invalidateByStatusId(statusId) {
const rec = await prisma.dailyStatus.findUnique({
where: { id: statusId },
select: { animal: { select: { experiment_id: true } } },
});
const expId = rec?.animal?.experiment_id;
if (expId) cache.delByPrefix(`exp:${expId}:`);
}
const statusValidation = [
body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'),
body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
@@ -71,6 +82,9 @@ router.post('/', statusValidation, validateRequest, async (req, res, next) => {
await prisma.auditLog.create({
data: { table_name: 'daily_statuses', record_id: status.id, action: 'CREATE', changes: { before: null, after: status } },
});
// Invalidate cached experiment data — new status may change calendar counts and chart data
const animal = await prisma.animal.findUnique({ where: { id: animal_id }, select: { experiment_id: true } });
if (animal) cache.delByPrefix(`exp:${animal.experiment_id}:`);
res.status(201).json(status);
} catch (err) { next(err); }
});
@@ -93,6 +107,7 @@ router.put(
if (custom_fields !== undefined) data.custom_fields = custom_fields;
const status = await prisma.dailyStatus.update({ where: { id: req.params.id }, data });
await invalidateByStatusId(req.params.id);
res.json(status);
} catch (err) { next(err); }
}
@@ -101,7 +116,10 @@ router.put(
// PUT /api/daily-statuses/:id/analysis-summary
router.put('/:id/analysis-summary', async (req, res, next) => {
try {
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } });
const status = await prisma.dailyStatus.findUnique({
where: { id: req.params.id },
include: { animal: { select: { experiment_id: true } } },
});
if (!status) return res.status(404).json({ error: 'Daily status not found' });
const { counts, total, success_rate, source_analysis_id } = req.body;
@@ -121,6 +139,7 @@ router.put('/:id/analysis-summary', async (req, res, next) => {
where: { id: req.params.id },
data: { analysis_summary: summary },
});
if (status.animal?.experiment_id) cache.delByPrefix(`exp:${status.animal.experiment_id}:`);
res.json(updated);
} catch (err) { next(err); }
});
@@ -131,7 +150,13 @@ router.delete(
auditMiddleware('daily_statuses', prisma.dailyStatus),
async (req, res, next) => {
try {
// Resolve experiment before deleting so we can invalidate after
const rec = await prisma.dailyStatus.findUnique({
where: { id: req.params.id },
select: { animal: { select: { experiment_id: true } } },
});
await prisma.dailyStatus.delete({ where: { id: req.params.id } });
if (rec?.animal?.experiment_id) cache.delByPrefix(`exp:${rec.animal.experiment_id}:`);
res.status(204).send();
} catch (err) { next(err); }
}
+27 -4
View File
@@ -1,6 +1,7 @@
const router = require('express').Router();
const { body } = require('express-validator');
const prisma = require('../lib/prisma');
const cache = require('../lib/cache');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const { v4: uuidv4 } = require('uuid');
@@ -311,10 +312,15 @@ router.put('/:id/subject-template', async (req, res, next) => {
// Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day.
router.get('/:id/calendar', async (req, res, next) => {
try {
const field = req.query.field ?? '';
const cacheKey = `exp:${req.params.id}:cal:${field}`;
const cached = cache.get(cacheKey);
if (cached) return res.json(cached);
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
const field = req.query.field ?? '';
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
const isBuiltin = BUILTIN_KEYS_SET.has(field);
@@ -339,22 +345,37 @@ router.get('/:id/calendar', async (req, res, next) => {
}
}
res.json({ field, days });
const result = { field, days };
cache.set(cacheKey, result);
res.json(result);
} catch (err) { next(err); }
});
// GET /api/experiments/:id/daily-statuses — all statuses for all subjects, analysis_summary included
// ?date=YYYY-MM-DD — filter to a single day
// ?analysisOnly=true — only return statuses that have a saved analysis_summary (for charts)
router.get('/:id/daily-statuses', async (req, res, next) => {
try {
const date = req.query.date ?? '';
const analysisOnly = req.query.analysisOnly === 'true';
const cacheKey = `exp:${req.params.id}:ds:${analysisOnly ? '1' : '0'}:${date}`;
const cached = cache.get(cacheKey);
if (cached) return res.json(cached);
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
const where = { animal: { experiment_id: req.params.id } };
if (req.query.date) {
const d = new Date(req.query.date + 'T00:00:00.000Z');
if (date) {
const d = new Date(date + 'T00:00:00.000Z');
const next = new Date(d); next.setUTCDate(next.getUTCDate() + 1);
where.date = { gte: d, lt: next };
}
if (analysisOnly) {
where.NOT = { analysis_summary: null };
}
const statuses = await prisma.dailyStatus.findMany({
where,
select: {
@@ -370,6 +391,8 @@ router.get('/:id/daily-statuses', async (req, res, next) => {
},
orderBy: { date: 'asc' },
});
cache.set(cacheKey, statuses);
res.json(statuses);
} catch (err) { next(err); }
});
+5 -1
View File
@@ -33,7 +33,11 @@ export const experimentsApi = {
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data),
updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data),
getDailyStatuses: (id) => api.get(`/experiments/${id}/daily-statuses`).then((r) => r.data),
// opts: { date?: string, analysisOnly?: boolean }
getDailyStatuses: (id, opts) => {
const params = typeof opts === 'string' ? { date: opts } : (opts ?? {});
return api.get(`/experiments/${id}/daily-statuses`, { params }).then((r) => r.data);
},
getDayStatuses: (id, date) => api.get(`/experiments/${id}/daily-statuses`, { params: { date } }).then((r) => r.data),
getCalendar: (id, field) => api.get(`/experiments/${id}/calendar`, { params: { field } }).then((r) => r.data),
};
+8 -14
View File
@@ -4,10 +4,9 @@ import {
getDay, addMonths, subMonths,
} from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { experimentsApi } from '../api/client';
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
export default function ExperimentCalendar({ experimentId, template, allStatuses }) {
export default function ExperimentCalendar({ experimentId, template }) {
const navigate = useNavigate();
const [currentMonth, setCurrentMonth] = useState(() => new Date());
const [selectedField, setSelectedField] = useState(null);
@@ -26,17 +25,12 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
useEffect(() => {
if (!selectedField) return;
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
const days = {};
allStatuses.forEach((s) => {
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField];
if (val !== null && val !== undefined && String(val).trim() !== '') {
const key = s.date.slice(0, 10);
days[key] = (days[key] || 0) + 1;
}
});
setCalendarDays(days);
}, [allStatuses, selectedField]);
let cancelled = false;
experimentsApi.getCalendar(experimentId, selectedField)
.then(({ days }) => { if (!cancelled) setCalendarDays(days); })
.catch(() => {});
return () => { cancelled = true; };
}, [experimentId, selectedField]);
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
+3 -2
View File
@@ -95,7 +95,9 @@ export default function ExperimentDetail() {
animalsApi.list(id),
experimentsApi.getSubjectTemplate(id),
experimentsApi.getTemplate(id),
experimentsApi.getDailyStatuses(id),
// Only fetch statuses that have saved analysis_summary — charts are the only consumer,
// and they filter for analysis_summary.total != null anyway.
experimentsApi.getDailyStatuses(id, { analysisOnly: true }),
]);
setExperiment(exp);
setAnimals(anims);
@@ -371,7 +373,6 @@ export default function ExperimentDetail() {
<ExperimentCalendar
experimentId={id}
template={dailyTemplate}
allStatuses={experimentStatuses}
/>
)}