perf(medications): batch the intake slot-dedup reads

The slot-dedup pass ran a findFirst (latest takenAt) plus a findMany
(all live rows) per medication — 2N round-trips, 40 for a 20-med user.
Pull every live intake row for the user's medication set in one ordered
findMany, group in memory by medicationId, and derive each medication's
lastIntakeAt (max non-null takenAt) from the same grouped slice the
findFirst used to scan. The read cost is now one query regardless of the
medication count.

Behaviour is unchanged: the grouped rows arrive in the same
scheduledFor-asc order the heal and snap passes depend on, and the
derived lastIntakeAt matches the old desc-ordered findFirst.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:21:23 +02:00
1 parent be7c5c5f5a
commit 9dcdf94db3
2 files changed
+112 -19

No files matched your search

@@ -177,3 +177,69 @@ describe("dedupeUserIntakeSlots — per-slot error isolation", () => {
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(2);
});
});
/**
* v1.20.2 F4 — the per-medication dedup read path is batched. The
* pre-v1.20.2 shape issued a `findFirst` (latest takenAt) + a `findMany`
* (all rows) PER medication → 2N round-trips. The batched path pulls
* every live row for the user's medication set in ONE `findMany`, groups
* in memory, and derives the per-med `lastIntakeAt` from the grouped set,
* so the read cost is one query regardless of the medication count.
*/
describe("dedupeUserIntakeSlots — batched read path (F4)", () => {
it("reads every medication's rows in a single findMany and never per-med findFirst", async () => {
const MED_2 = { ...MED, id: "med-2" };
vi.mocked(prisma.medication.findMany).mockResolvedValue([
MED,
MED_2,
] as never);
// Rows for BOTH medications come back in one ordered result set; the
// grouping keys them apart by `medicationId`. med-2 carries a single
// row (no duplicate slot) so it is a no-op — proving the grouping
// routes each med's slice correctly.
const med2Row = {
id: "r-m2",
medicationId: "med-2",
scheduledFor: new Date("2026-06-15T05:00:00.000Z"),
takenAt: new Date("2026-06-15T05:02:00.000Z"),
skipped: false,
syncVersion: 0,
createdAt: new Date("2026-06-15T00:00:00Z"),
attributionSource: "AUTO",
inventoryConsumption: null,
};
// `inventoryConsumption: null` on every row keeps the loser-refund
// path (which itself runs a `findFirst` inside `restoreForIntake`)
// out of the picture, so the findFirst assertion isolates the removed
// lastIntake probe.
const med1Rows = seedRows().map((r) => ({
...r,
inventoryConsumption: null,
}));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
...med1Rows,
med2Row,
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
{} as never,
);
const summary = await dedupeUserIntakeSlots("u1");
// ONE findMany for the whole medication set; the per-med findFirst is
// gone entirely.
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledTimes(1);
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
// The single read is keyed by the medication-id set, not per med.
const whereArg = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0] as {
where: { medicationId: { in: string[] } };
};
expect(whereArg.where.medicationId.in.sort()).toEqual(["med-1", "med-2"]);
// med-1's two slots still collapse from its grouped slice; med-2's
// lone row is left alone.
expect(summary.slotsCollapsed).toBe(2);
});
});
+46 -19
View File
@@ -242,25 +242,24 @@ export async function dedupeUserIntakeSlots(
// recompute after the collapse. Deduped so a busy day folds once.
const daysToRecompute = new Set<string>();
for (const med of medications) {
if (med.schedules.length === 0) continue;
// v1.20.2 F4 — batch the per-medication reads. The pre-v1.20.2 shape
// issued a `findFirst` (latest takenAt) + a `findMany` (all live rows)
// PER medication → 2N round-trips (20 meds = 40). Pull every live row
// for the user's medication set in ONE ordered `findMany`, group in
// memory by `medicationId`, and derive the per-med `lastIntakeAt` (max
// non-null takenAt) from the same grouped set the `findFirst` would
// have scanned. The grouping preserves the `scheduledFor asc` order the
// snap + heal passes depend on (a stable Postgres sort keeps the
// per-medication slices in the same order a per-med query returned).
const medIds = medications
.filter((m) => m.schedules.length > 0)
.map((m) => m.id);
// Latest non-tombstoned takenAt — only rolling schedules consult it,
// but the snap resolver needs it threaded in.
const lastIntake = await prisma.medicationIntakeEvent.findFirst({
where: {
userId,
medicationId: med.id,
deletedAt: null,
takenAt: { not: null },
},
orderBy: { takenAt: "desc" },
select: { takenAt: true },
});
const lastIntakeAt = lastIntake?.takenAt ?? null;
const rows = (await prisma.medicationIntakeEvent.findMany({
where: { userId, medicationId: med.id, deletedAt: null },
const rowsByMedication = new Map<string, DedupIntakeRow[]>();
const lastIntakeAtByMedication = new Map<string, Date | null>();
if (medIds.length > 0) {
const allRows = (await prisma.medicationIntakeEvent.findMany({
where: { userId, medicationId: { in: medIds }, deletedAt: null },
select: {
id: true,
medicationId: true,
@@ -276,9 +275,37 @@ export async function dedupeUserIntakeSlots(
// v1.16.10 — stamped losers refund their consumption on collapse.
inventoryConsumption: true,
},
orderBy: { scheduledFor: "asc" },
// `medicationId` first so the per-med slices are contiguous, then the
// `scheduledFor asc` the loop relies on.
orderBy: [{ medicationId: "asc" }, { scheduledFor: "asc" }],
})) as DedupIntakeRow[];
for (const row of allRows) {
const list = rowsByMedication.get(row.medicationId);
if (list) {
list.push(row);
} else {
rowsByMedication.set(row.medicationId, [row]);
}
// Latest non-tombstoned takenAt per medication — the same value the
// per-med `findFirst({ takenAt: { not: null }, orderBy: desc })`
// returned. Only rolling schedules consult it, but the snap resolver
// needs it threaded in.
if (row.takenAt !== null) {
const prev = lastIntakeAtByMedication.get(row.medicationId) ?? null;
if (prev === null || row.takenAt.getTime() > prev.getTime()) {
lastIntakeAtByMedication.set(row.medicationId, row.takenAt);
}
}
}
}
for (const med of medications) {
if (med.schedules.length === 0) continue;
const lastIntakeAt = lastIntakeAtByMedication.get(med.id) ?? null;
const rows = rowsByMedication.get(med.id) ?? [];
if (rows.length < 2) continue;
// v1.16.9 — heal pass FIRST: converge ad-hoc actioned API/REMINDER