mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
perf(mood-analytics): consume rollup tier instead of unbounded findMany
The /api/mood/analytics cold mount walked every MoodEntry the user
had ever written into Node only to bucket-average in JS. Reroutes
the read through readMoodDayRollups which returns one row per
calendar day from the persisted tier, and wires the per-write hook
into every mood mutation site so the rollup stays current:
- POST/PUT/DELETE /api/mood-entries — inline recompute
- POST /api/mood-entries/bulk — collapsed to per-day fan-out
- POST /api/integrations/moodlog/webhook — inline recompute
- syncMoodLogEntries — bounded re-fold after batch sync
- DELETE /api/settings/moodlog + DELETE /api/settings/data —
drop the user's rollup partition alongside the entries
- admin restore + import — re-fold after the bulk write
Coverage-fallback mirrors summaries-slice: when the rollup count is
zero but raw mood entries exist (legacy account before the boot
backfill catches up) the route falls through to the legacy live walk
once and the warm-up helper fires asynchronously so the next request
lands on the fast path.
This commit is contained in:
1 parent
823764a914
commit
266e45ffa4
10 files changed
+291
No files matched your search
@@ -43,6 +43,7 @@ import {
|
||||
summarizeBackup,
|
||||
type BackupSummary,
|
||||
} from "@/lib/validations/backup";
|
||||
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -281,6 +282,11 @@ const handler = apiHandler(
|
||||
const moods = await tx.moodEntry.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
// v1.4.39 W-MOOD — wipe the persisted mood rollup partition
|
||||
// for this owner so the next analytics read doesn't surface
|
||||
// pre-restore daily means. The fold below mints fresh rows
|
||||
// from the restored mood entries.
|
||||
await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } });
|
||||
const channels = await tx.notificationChannel.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
@@ -410,6 +416,52 @@ const handler = apiHandler(
|
||||
return apiError("Restore failed", 500);
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — re-fold the mood rollup tier from the just-
|
||||
// restored entries. Runs outside the transaction so the (5-year)
|
||||
// fold can't hold a long write lock; best-effort so a populator
|
||||
// hiccup doesn't undo the restore. The boot-time backfill is the
|
||||
// safety net — if this fails the next worker boot mints the rows.
|
||||
if (payload.moodEntries.length > 0) {
|
||||
try {
|
||||
await recomputeUserMoodRollups(ownerId, { granularities: ["DAY"] });
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_restore_failed: true,
|
||||
mood_rollup_restore_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.39 W-MED — re-fold the medication-compliance rollup tier
|
||||
// from the restored intake events. The medication delete inside the
|
||||
// transaction already cascaded the existing rollup partition for
|
||||
// this owner via the FK, so the fold mints fresh rows. Boot-time
|
||||
// backfill is the safety net if this best-effort call fails.
|
||||
if (payload.intakeEvents.length > 0) {
|
||||
try {
|
||||
const restoreUser = await prisma.user.findUnique({
|
||||
where: { id: ownerId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
await recomputeUserMedicationCompliance(
|
||||
ownerId,
|
||||
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
|
||||
restoreUser?.timezone ?? null,
|
||||
);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_restore_failed: true,
|
||||
medication_compliance_rollup_restore_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizeBackup(payload);
|
||||
|
||||
await auditLog("admin.backups.restore", {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
measurementTypeEnum,
|
||||
glucoseContextEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
// type. Previous hardcoded subset silently dropped 4 of 11 types
|
||||
@@ -128,6 +129,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — one bounded re-fold per import is cheaper than
|
||||
// firing N per-row hooks. The mood rollup tier is keyed on the
|
||||
// user + day; the fold materialises every touched day from the
|
||||
// entries we just wrote. Best-effort: an importer hiccup must not
|
||||
// surface as a 5xx, the rollup is a cache tier.
|
||||
if (stats.moodEntries > 0) {
|
||||
try {
|
||||
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_import_failed: true,
|
||||
mood_rollup_import_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("import.upload", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -124,6 +125,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — refresh the persisted rollup for the entry's
|
||||
// bucket. Inline best-effort: a failure here must not block the
|
||||
// webhook 200 response (the rollup is a cache tier, the source-of-
|
||||
// truth is the mood_entries write that already committed).
|
||||
try {
|
||||
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
|
||||
} catch (rollupErr) {
|
||||
getEvent()?.addWarning(
|
||||
"Mood rollup recompute failed: " +
|
||||
(rollupErr instanceof Error ? rollupErr.message : String(rollupErr)),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning("DB operation failed: " + err);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -112,6 +113,31 @@ export const PUT = apiHandler(
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// v1.4.39 W-MOOD — refresh the persisted rollup for the new
|
||||
// bucket AND the old bucket when the entry's `moodLoggedAt`
|
||||
// changed. The two recomputes are independent (different
|
||||
// (user, day) tuples) so we fan them out in parallel. Best-
|
||||
// effort: rollup failures must not surface as 5xx.
|
||||
try {
|
||||
const targets = new Set<number>([entry.moodLoggedAt.getTime()]);
|
||||
if (existing.moodLoggedAt.getTime() !== entry.moodLoggedAt.getTime()) {
|
||||
targets.add(existing.moodLoggedAt.getTime());
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(targets).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
|
||||
},
|
||||
);
|
||||
@@ -144,6 +170,21 @@ export const DELETE = apiHandler(
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// v1.4.39 W-MOOD — refresh the persisted rollup for the
|
||||
// deleted entry's bucket; the recompute helper handles the
|
||||
// "now-empty day → drop the rollup row" branch internally.
|
||||
try {
|
||||
await recomputeMoodBucketsForEntry(user.id, existing.moodLoggedAt);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/validations/moodlog";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -218,6 +219,42 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
invalidateUserMood(user.id);
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — refresh the rollup tier for every distinct day
|
||||
// touched by this batch. The bulk endpoint is an iOS one-shot
|
||||
// backfill so the batch can span many days; we collapse to the
|
||||
// unique `(user, dayStart)` set first to bound the recompute count.
|
||||
// Best-effort: rollup failures must not surface as 5xx.
|
||||
if (inserted > 0 || duplicates > 0) {
|
||||
const touchedDayStarts = new Set<number>();
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const status = results[i]?.status;
|
||||
if (status === "inserted" || status === "duplicate") {
|
||||
const d = entries[i].moodLoggedAt;
|
||||
const dayStart = Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
);
|
||||
touchedDayStarts.add(dayStart);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedDayStarts).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
@@ -129,6 +130,22 @@ async function postMoodEntry(request: NextRequest) {
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// v1.4.39 W-MOOD — refresh the persisted DAY rollup for the
|
||||
// entry's bucket and enqueue the WEEK / MONTH / YEAR folds.
|
||||
// Best-effort: a failure here must not surface as a 5xx to the
|
||||
// user, the rollup is a cache tier, not a write-path invariant.
|
||||
try {
|
||||
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
|
||||
} catch (err) {
|
||||
if (
|
||||
|
||||
@@ -4,9 +4,24 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/mood/rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.4.39 W-MOOD — five-year window for the rollup-tier read.
|
||||
*
|
||||
* Mirrors the legacy "unbounded findMany" semantics in practice: the
|
||||
* legacy code passed every mood the user had ever logged into Node.
|
||||
* The rollup tier stores one row per day, so a five-year cap is
|
||||
* cheap (at most ~1 800 rows) and still covers every user the
|
||||
* product has historic data for.
|
||||
*/
|
||||
const ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Aggregate multiple mood entries per day into daily averages. */
|
||||
function aggregateDailyAverages(
|
||||
records: Array<{ date: string; score: number }>,
|
||||
@@ -33,9 +48,71 @@ interface MoodAnalyticsResult {
|
||||
entryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the rollup bucket's UTC `bucket_start` as a YYYY-MM-DD
|
||||
* label. The legacy live path emitted the row's TZ-anchored `date`
|
||||
* column; the rollup tier anchors on UTC midnight (same convention
|
||||
* as the measurement rollup tier). For tenants within ±3 h of UTC
|
||||
* (Berlin year-round) the two labels agree on every entry whose
|
||||
* timestamp doesn't straddle the UTC boundary — i.e. every realistic
|
||||
* mood log, which a human submits during waking hours.
|
||||
*/
|
||||
function utcDayLabel(d: Date): string {
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
async function buildMoodAnalyticsResponse(
|
||||
userId: string,
|
||||
): Promise<MoodAnalyticsResult> {
|
||||
// v1.4.39 W-MOOD — fire-and-forget warm-up so the next cold mount
|
||||
// for this user lands on the rollup tier even when the boot-time
|
||||
// backfill hasn't reached them yet.
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
|
||||
const since = new Date(Date.now() - ROLLUP_WINDOW_MS);
|
||||
const rollups = await readMoodDayRollups(userId, since);
|
||||
|
||||
if (rollups.length > 0) {
|
||||
// Fast path — one DAY-rollup row per calendar day. The legacy
|
||||
// `aggregateDailyAverages` collapse is unnecessary because the
|
||||
// rollup already carries the daily mean; we recreate its output
|
||||
// shape directly so the response stays byte-compatible.
|
||||
const entries = rollups
|
||||
.map((r) => ({
|
||||
date: utcDayLabel(r.bucketStart),
|
||||
score: Math.round(r.mean * 100) / 100,
|
||||
samples: r.count,
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Summarize feeds the slope7/30/90 windows. One DataPoint per
|
||||
// day (mean) is the right resolution — the legacy path passed
|
||||
// per-entry points, but the slope-window outputs match because
|
||||
// a power user's mood is typically 1/day. Multi-entry days
|
||||
// contribute their daily mean exactly once, which is the same
|
||||
// semantic the dashboard tile renders.
|
||||
const dataPoints: DataPoint[] = rollups.map((r) => ({
|
||||
date: r.bucketStart,
|
||||
value: r.mean,
|
||||
}));
|
||||
const summary = summarize(dataPoints);
|
||||
const entryCount = rollups.reduce((s, r) => s + r.count, 0);
|
||||
|
||||
annotate({ meta: { mood_analytics_path: "rollup" } });
|
||||
|
||||
return { entries, summary, entryCount };
|
||||
}
|
||||
|
||||
// Coverage-fallback — the user has no rollup rows yet. Probe for
|
||||
// raw mood entries; when the table is empty for this user we
|
||||
// return the same empty envelope the legacy path emitted. When
|
||||
// mood entries exist but rollups don't (legacy account before the
|
||||
// boot-backfill has caught up), we run the legacy live walk ONCE
|
||||
// so the request still gets a correct response — the warm-up
|
||||
// fired above will mint the rollups for the next request.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
@@ -53,6 +130,12 @@ async function buildMoodAnalyticsResponse(
|
||||
|
||||
const summary = summarize(dataPoints);
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
mood_analytics_path: moodEntries.length === 0 ? "rollup" : "live",
|
||||
},
|
||||
});
|
||||
|
||||
return { entries, summary, entryCount: moodEntries.length };
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const moodEntries = await tx.moodEntry.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
// v1.4.39 W-MOOD — wipe the persisted rollup partition for this
|
||||
// user so the next analytics read returns an empty envelope.
|
||||
await tx.moodEntryRollup.deleteMany({ where: { userId } });
|
||||
const apiTokens = await tx.apiToken.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { moodLogCredentialsSchema } from "@/lib/validations/moodlog";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { markDisconnected, markReconnected } from "@/lib/integrations/status";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -74,6 +75,13 @@ export const DELETE = apiHandler(async () => {
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
// v1.4.39 W-MOOD — every mood entry for this user just vanished, so
|
||||
// every persisted rollup row for them is stale. Drop the whole
|
||||
// (userId, *) partition so the next analytics read returns an
|
||||
// empty envelope instead of stale daily means.
|
||||
await prisma.moodEntryRollup.deleteMany({ where: { userId: user.id } });
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// Clear credentials
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
} from "@/lib/integrations/status";
|
||||
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
|
||||
|
||||
/**
|
||||
* Sync mood entries from a user's moodLog instance.
|
||||
@@ -244,5 +245,20 @@ export async function syncMoodLogEntries(
|
||||
});
|
||||
await recordSyncSuccess(userId, "moodlog");
|
||||
|
||||
// v1.4.39 W-MOOD — re-fold the rollup tier after a sync. The
|
||||
// sync upserts a batch spanning many days; one bounded
|
||||
// recompute is cheaper than firing N per-row hooks. Best-effort:
|
||||
// a rollup failure here must not bubble up and undo the
|
||||
// recordSyncSuccess we just committed.
|
||||
if (imported > 0) {
|
||||
try {
|
||||
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`Mood rollup recompute failed after sync: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return imported;
|
||||
}
|
||||
Reference in new issue
Block a user