mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user