perf(environment): batch the discovery gate reads and the backfill upserts

The nightly discovery fan-out called isModuleEnabled per candidate user —
N serial DB reads on a large instance. Resolve the opt-in gate in-memory
from one findMany plus a single operator-availability read instead. The
per-day backfill upserts ran one awaited statement at a time (up to 730
for a full backfill); batch each location group's upserts into a single
transaction round-trip while keeping the idempotent semantics.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 18:30:27 +02:00
1 parent e43089df39
commit e264f096ed
2 files changed
+47 -18

No files matched your search

+15 -9
View File
@@ -230,25 +230,31 @@ export async function fetchAndStoreEnvironment(args: {
}
fetches += 1;
const obsByDate = new Map(observations.map((o) => [o.date, o]));
const wanted = new Set(groupDays);
for (const obs of observations) {
if (!wanted.has(obs.date)) continue;
await upsertObservation(userId, loc, obs);
stored += 1;
// Batch the group's day upserts into a single transaction round-trip
// rather than awaiting one upsert per day in series (a 730-day backfill
// would otherwise issue 730 sequential statements). One transaction per
// location group keeps the per-backfill DB chatter bounded while
// preserving the idempotent upsert semantics.
const ops = observations
.filter((obs) => wanted.has(obs.date))
.map((obs) => buildUpsert(userId, loc, obs));
if (ops.length > 0) {
await prisma.$transaction(ops);
stored += ops.length;
}
// Days the feed did not return (e.g. beyond the settling lag) stay absent.
void obsByDate;
}
return { stored, skipped, fetches };
}
async function upsertObservation(
/** Build the idempotent per-day upsert operation (un-awaited, for a batch). */
function buildUpsert(
userId: string,
loc: ResolvedLocation,
obs: DailyEnvironmentObservation,
): Promise<void> {
) {
const data = {
lat: loc.lat,
lon: loc.lon,
@@ -268,7 +274,7 @@ async function upsertObservation(
weatherCode: obs.weatherCode,
fetchedAt: new Date(),
};
await prisma.environmentContext.upsert({
return prisma.environmentContext.upsert({
where: { userId_date: { userId, date: obs.date } },
create: { userId, date: obs.date, ...data },
update: data,
+32 -9
View File
@@ -20,7 +20,12 @@
import { prisma } from "@/lib/db";
import type { PgBoss } from "pg-boss";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { isModuleEnabled } from "@/lib/modules/gate";
import {
getOperatorModuleAvailability,
isModuleEnabled,
normalisePrefs,
resolveModuleEnabled,
} from "@/lib/modules/gate";
import {
ENVIRONMENT_LOOKBACK_DAYS,
ENVIRONMENT_MAX_BACKFILL_DAYS,
@@ -96,17 +101,35 @@ export async function enqueueEnvironmentFetchDiscovery(
boss: PgBoss,
): Promise<{ enqueued: number; skipped: number }> {
// Candidate set: a home location is required for any non-override day, so an
// account without one cannot produce a row. The per-candidate module check
// then enforces the opt-in flag + operator availability.
const candidates = await prisma.user.findMany({
where: { homeLat: { not: null }, homeLon: { not: null } },
select: { id: true },
});
// account without one cannot produce a row. The opt-in flag + operator
// availability are then resolved in-memory from the candidates' preference
// blobs — one operator-availability read + one `findMany`, instead of an
// `isModuleEnabled` round-trip per candidate (N serial reads on a large
// instance). `environment` is opt-in and non-delegated, so the resolver only
// consults the per-user preference map + the operator availability.
const [candidates, operatorAvailability] = await Promise.all([
prisma.user.findMany({
where: { homeLat: { not: null }, homeLon: { not: null } },
select: { id: true, modulePreferencesJson: true },
}),
getOperatorModuleAvailability(),
]);
let enqueued = 0;
let skipped = 0;
for (const { id } of candidates) {
if (!(await isModuleEnabled(id, "environment"))) {
for (const { id, modulePreferencesJson } of candidates) {
const isEnabled = resolveModuleEnabled(
"environment",
{
gender: null,
disableCoach: false,
modulePreferences: normalisePrefs(modulePreferencesJson),
cycleTrackingEnabled: null,
},
false,
operatorAvailability,
);
if (!isEnabled) {
skipped += 1;
continue;
}