mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
perf(medications): batch the intake CSV import duplicate probe
The CSV import loop ran a findUnique duplicate check before every single create — up to 2000 sequential round trips on a 1000-row import. Resolve takenAt + the idempotency key for every row first, then read every key's existence in one indexed query before the write loop; create and the per-row inventory consumption stay unchanged. Also runs prettier over the batch-route test files touched in the prior two commits.
This commit is contained in:
1 parent
2c6699f682
commit
e3dfe9f400
7 files changed
+91
-39
No files matched your search
@@ -190,15 +190,15 @@ describe("GET /api/insights/cards", () => {
|
||||
pulseRows.push({ value: i < 3 ? 300 : 68, measuredAt: at });
|
||||
}
|
||||
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
(async (args: { where?: { type?: unknown } }) => {
|
||||
// `readDayMeanSeries`'s live-fallback probes a single `type`
|
||||
// equality filter; the manual WEIGHT/BP query below probes an
|
||||
// `{ in: [...] }` list — the two calls are distinguishable by shape.
|
||||
if (args?.where?.type === "PULSE") return pulseRows;
|
||||
return [];
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
|
||||
where?: { type?: unknown };
|
||||
}) => {
|
||||
// `readDayMeanSeries`'s live-fallback probes a single `type`
|
||||
// equality filter; the manual WEIGHT/BP query below probes an
|
||||
// `{ in: [...] }` list — the two calls are distinguishable by shape.
|
||||
if (args?.where?.type === "PULSE") return pulseRows;
|
||||
return [];
|
||||
}) as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
@@ -216,13 +216,12 @@ describe("GET /api/insights/cards", () => {
|
||||
.mocked(prisma.measurement.findMany)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
typeof (call[0] as { where?: { type?: { in?: unknown[] } } })
|
||||
?.where?.type === "object",
|
||||
typeof (call[0] as { where?: { type?: { in?: unknown[] } } })?.where
|
||||
?.type === "object",
|
||||
);
|
||||
expect(manualCall).toBeDefined();
|
||||
const typeIn = (
|
||||
manualCall?.[0] as { where: { type: { in: string[] } } }
|
||||
).where.type.in;
|
||||
const typeIn = (manualCall?.[0] as { where: { type: { in: string[] } } })
|
||||
.where.type.in;
|
||||
expect(typeIn).not.toContain("PULSE");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* /api/medications/[id]/intake/import. Preserves the
|
||||
* `medication.intake.import.invalid_format` errorCode meta so the CSV
|
||||
* import client UI can branch on the prefix semantics.
|
||||
*
|
||||
* v1.29 perf fix — the per-row `findUnique` duplicate probe was replaced by
|
||||
* one indexed `findMany` existence read over every row's idempotencyKey
|
||||
* before the write loop. `create` + `consumeForIntake` stay per-row.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -13,7 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
@@ -75,6 +79,12 @@ function postReq(body: unknown): NextRequest {
|
||||
|
||||
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
|
||||
|
||||
/** Mirrors the route's own dedup-key derivation for a zaehler-less row. */
|
||||
function keyFor(medId: string, datum: string, uhrzeit: string): string {
|
||||
const takenAt = new Date(`${datum}T${uhrzeit}`);
|
||||
return `import-${medId}-${takenAt.getTime()}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
@@ -172,10 +182,9 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43
|
||||
|
||||
describe("POST /api/medications/[id]/intake/import — inventory consumption", () => {
|
||||
beforeEach(() => {
|
||||
// Fresh imports: no pre-existing dedup row, and create echoes an id.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
// Fresh imports: the batched existence read finds no prior rows, and
|
||||
// create echoes an id.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
|
||||
let n = 0;
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockImplementation(
|
||||
(async () => ({ id: `evt-${++n}`, takenAt: new Date() })) as never,
|
||||
@@ -200,16 +209,24 @@ describe("POST /api/medications/[id]/intake/import — inventory consumption", (
|
||||
expect(call?.userId).toBe("user-1");
|
||||
expect(call?.eventId).toBe("evt-1");
|
||||
|
||||
// The three-row import reads existence in ONE indexed query, not one
|
||||
// findUnique probe per row.
|
||||
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledTimes(1);
|
||||
const findManyArg = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
|
||||
.calls[0]?.[0] as { where: { idempotencyKey: { in: string[] } } };
|
||||
expect(findManyArg.where.idempotencyKey.in).toHaveLength(3);
|
||||
|
||||
// (#22) — the whole import queues exactly ONE cross-device sync
|
||||
// fan-out, not one per imported row.
|
||||
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not consume for duplicate (already-imported) rows", async () => {
|
||||
// First entry is a duplicate (existing dedup row), second is fresh.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique)
|
||||
.mockResolvedValueOnce({ id: "existing" } as never)
|
||||
.mockResolvedValueOnce(null as never);
|
||||
// First entry's key is already present — the batched existence read
|
||||
// reports it, the second entry's key is absent (fresh).
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{ idempotencyKey: keyFor("m1", "2026-01-01", "07:00:00") },
|
||||
] as never);
|
||||
const res = await POST(
|
||||
postReq([
|
||||
{ datum: "2026-01-01", uhrzeit: "07:00:00" },
|
||||
|
||||
@@ -99,6 +99,20 @@ export const POST = apiHandler(
|
||||
// so the rollup pass fires once per day rather than once per row.
|
||||
const touchedDays = new Set<string>();
|
||||
|
||||
// Perf — a CSV import can carry up to 1000 rows; the pre-fix loop ran a
|
||||
// `findUnique` duplicate probe before every single `create`, up to 2000
|
||||
// sequential round trips. Resolve `takenAt` + the dedup key for every
|
||||
// row first, then read every key's existence in ONE indexed query
|
||||
// (`idempotencyKey` carries its own unique index) before the write
|
||||
// loop. `create` + `consumeForIntake` (inventory decrement) stay
|
||||
// per-row — each has a real side effect keyed off the freshly-created
|
||||
// event's id, so they aren't safely batchable without a deeper change
|
||||
// to the inventory-consumption path.
|
||||
interface PreparedIntake {
|
||||
takenAt: Date;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
const prepared: PreparedIntake[] = [];
|
||||
for (const entry of entries) {
|
||||
// Parse as local datetime first; if invalid, fall back to CET offset.
|
||||
let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`);
|
||||
@@ -115,10 +129,26 @@ export const POST = apiHandler(
|
||||
? `import-${id}-${String(entry.zaehler)}`
|
||||
: `import-${id}-${takenAt.getTime()}`;
|
||||
|
||||
const existing = await prisma.medicationIntakeEvent.findUnique({
|
||||
where: { idempotencyKey },
|
||||
});
|
||||
if (existing) {
|
||||
prepared.push({ takenAt, idempotencyKey });
|
||||
}
|
||||
|
||||
const existingRows =
|
||||
prepared.length > 0
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
idempotencyKey: { in: prepared.map((p) => p.idempotencyKey) },
|
||||
},
|
||||
select: { idempotencyKey: true },
|
||||
})
|
||||
: [];
|
||||
const existingKeys = new Set(
|
||||
existingRows
|
||||
.map((r) => r.idempotencyKey)
|
||||
.filter((k): k is string => k !== null),
|
||||
);
|
||||
|
||||
for (const { takenAt, idempotencyKey } of prepared) {
|
||||
if (existingKeys.has(idempotencyKey)) {
|
||||
skippedDuplicates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -321,7 +321,8 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
|
||||
};
|
||||
expect(json.data.inserted).toBe(1);
|
||||
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as {
|
||||
const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock
|
||||
.calls[0]?.[0] as {
|
||||
where: { userId: string; OR: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(findManyArg.where.OR).toContainEqual({
|
||||
@@ -349,7 +350,8 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
|
||||
const json = (await res.json()) as {
|
||||
data: { entries: Array<{ externalId?: string }> };
|
||||
};
|
||||
const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as {
|
||||
const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock
|
||||
.calls[0]?.[0] as {
|
||||
where: { OR: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(findManyArg.where.OR).toContainEqual({
|
||||
|
||||
@@ -209,7 +209,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
> = entries.map((entry) =>
|
||||
entry.externalId
|
||||
? { source: entry.source, externalId: entry.externalId }
|
||||
: { date: moodDateKey(entry.moodLoggedAt, tz), moodLoggedAt: entry.moodLoggedAt },
|
||||
: {
|
||||
date: moodDateKey(entry.moodLoggedAt, tz),
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
);
|
||||
const existingRows =
|
||||
orClauses.length > 0
|
||||
@@ -231,7 +234,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
if (row.externalId !== null) {
|
||||
existingByExternalKey.set(`${row.source}::${row.externalId}`, row);
|
||||
}
|
||||
existingByDateKey.set(`${row.date}::${row.moodLoggedAt.toISOString()}`, row);
|
||||
existingByDateKey.set(
|
||||
`${row.date}::${row.moodLoggedAt.toISOString()}`,
|
||||
row,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
@@ -271,8 +277,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const existing =
|
||||
(entry.externalId
|
||||
? existingByExternalKey.get(`${resolvedSource}::${entry.externalId}`)
|
||||
: existingByDateKey.get(`${date}::${entry.moodLoggedAt.toISOString()}`)) ??
|
||||
null;
|
||||
: existingByDateKey.get(
|
||||
`${date}::${entry.moodLoggedAt.toISOString()}`,
|
||||
)) ?? null;
|
||||
|
||||
// Tombstone suppression: a soft-deleted match stays deleted. The
|
||||
// user-facing DELETE route flips `deletedAt` so the `/api/sync/changes`
|
||||
|
||||
@@ -113,9 +113,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({} as never);
|
||||
// Real Prisma resolves every promise in the `$transaction([...])` array;
|
||||
// the mock does the same so the update path actually runs in these tests.
|
||||
vi.mocked(prisma.$transaction).mockImplementation(
|
||||
(async (ops: unknown[]) => Promise.all(ops)) as never,
|
||||
);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (ops: unknown[]) =>
|
||||
Promise.all(ops)) as never);
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — module gate", () => {
|
||||
|
||||
@@ -35,9 +35,7 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/derived/baseline", () => ({
|
||||
readDayMeanSeries: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ points: [], source: "none" }),
|
||||
readDayMeanSeries: vi.fn().mockResolvedValue({ points: [], source: "none" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/analytics/intraday-pulse-io", () => ({
|
||||
|
||||
Reference in new issue
Block a user