mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Rollup writes were delete-then-createMany inside a transaction; under READ COMMITTED a concurrent recompute racing the import's full fold on the same (user, type, granularity, bucket, source) partition tripped the composite PK — and the P2002 swallow dropped the loser's freshly computed aggregate, leaving a stale bucket. persistRollupRows now writes chunked INSERT … ON CONFLICT DO UPDATE with deterministic (type, source, bucket) lock ordering inside one transaction: a race can neither fail the import nor lose an aggregate, and previously-staled buckets self-heal on their next recompute. The vanished-source delete and in-transaction guarantees are preserved; the now-unreachable P2002 swallow is removed. The masked ENOENT is gone with it: long imports outlived the queue's 15-minute expiry, pg-boss redelivered the still-running job, and the redelivery re-opened the consumed staging file — overwriting the real terminal state with a raw ENOENT. Import sends now carry retryLimit 0 + 6h expiry (a retry after the staging file is consumed can only mask), deliveries for terminal ImportJob rows are no-ops, a genuinely missing staging file reports an honest re-upload message, and the extracted XML is cleaned up on failure too.
294 lines
9.4 KiB
TypeScript
294 lines
9.4 KiB
TypeScript
/**
|
|
* v1.28.33 — concurrency contract for the measurement-rollup persist
|
|
* path (issue #486).
|
|
*
|
|
* A cumulative Apple Health `export.zip` re-import runs the end-of-import
|
|
* full fold while the regular write hooks (iOS batch sync, dashboard
|
|
* warm-up) keep firing their own per-day recomputes. Two writers on the
|
|
* same `(userId, type, granularity, bucketStart, source)` partition used
|
|
* to interleave delete-then-insert, and the losing INSERT tripped the
|
|
* `measurement_rollups_pkey` composite primary key (Postgres 23505). The
|
|
* previous shape swallowed the P2002 and DROPPED the loser's freshly
|
|
* computed aggregate — which is exactly wrong when the loser saw newer
|
|
* measurements than the winner: the bucket stayed stale until the next
|
|
* unrelated write.
|
|
*
|
|
* The persist path now writes through `INSERT … ON CONFLICT … DO UPDATE`
|
|
* so the later recompute overwrites with its freshly computed values
|
|
* (last-write-wins) instead of raising or dropping. Pinned here against
|
|
* the real Postgres:
|
|
*
|
|
* 1. **Racing peer holds the partition uncommitted.** A peer
|
|
* transaction inserts a stale aggregate for the bucket and holds
|
|
* its transaction open; the real write-hook recompute (which sees
|
|
* one more measurement) runs concurrently and blocks on the PK.
|
|
* When the peer commits, the hook's fresher aggregate must land —
|
|
* not vanish into a swallowed unique violation.
|
|
*
|
|
* 2. **Two full user folds racing.** `recomputeUserRollups` twice in
|
|
* parallel neither throws nor duplicates rows.
|
|
*
|
|
* 3. **N-way write-hook fan-out.** Concurrent
|
|
* `recomputeBucketsForMeasurement` calls for the same day all
|
|
* resolve and converge on the correct aggregate.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import {
|
|
recomputeBucketsForMeasurement,
|
|
recomputeUserRollups,
|
|
} from "@/lib/rollups/measurement-rollups";
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
// No pg-boss in the integration harness — the WEEK/MONTH/YEAR enqueue
|
|
// inside the write hook is a silent no-op when no boss is attached.
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: vi.fn(() => null),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
/**
|
|
* Poll `pg_stat_activity` until some backend is lock-waiting on a
|
|
* `measurement_rollups` statement (the hook's upsert queued behind the
|
|
* peer's uncommitted insert). Bounded so a scheduling hiccup degrades to
|
|
* a plain sleep rather than a hang.
|
|
*/
|
|
async function waitForRollupLockWaiter(timeoutMs = 5_000): Promise<boolean> {
|
|
const prisma = getPrismaClient();
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const rows = await prisma.$queryRaw<Array<{ n: bigint }>>`
|
|
SELECT count(*)::bigint AS n
|
|
FROM pg_stat_activity
|
|
WHERE wait_event_type = 'Lock'
|
|
AND query ILIKE '%measurement_rollups%'
|
|
`;
|
|
if (Number(rows[0]?.n ?? 0) > 0) return true;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
describe("measurement rollups — concurrent recompute (issue #486)", () => {
|
|
it("a recompute racing a committed peer write keeps the fresher aggregate", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "rollup-race-user",
|
|
email: "rollup-race-user@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
const dayStart = new Date("2026-04-22T00:00:00.000Z");
|
|
const m1At = new Date("2026-04-22T07:00:00.000Z");
|
|
const m2At = new Date("2026-04-22T19:00:00.000Z");
|
|
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: m1At,
|
|
},
|
|
});
|
|
|
|
// Peer transaction: mimics the end-of-import fold writing the bucket
|
|
// from an aggregate snapshot taken BEFORE the second measurement
|
|
// landed (count 1, mean 80). It holds its transaction open so the
|
|
// real write hook below queues behind the uncommitted PK row —
|
|
// the exact interleaving the reporter's Postgres log shows.
|
|
let releasePeer!: () => void;
|
|
const gate = new Promise<void>((r) => {
|
|
releasePeer = r;
|
|
});
|
|
let peerInserted!: () => void;
|
|
const peerReady = new Promise<void>((r) => {
|
|
peerInserted = r;
|
|
});
|
|
const peer = prisma.$transaction(
|
|
async (tx) => {
|
|
await tx.measurementRollup.deleteMany({
|
|
where: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
granularity: "DAY",
|
|
bucketStart: dayStart,
|
|
},
|
|
});
|
|
await tx.measurementRollup.createMany({
|
|
data: [
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
granularity: "DAY",
|
|
bucketStart: dayStart,
|
|
source: "APPLE_HEALTH",
|
|
count: 1,
|
|
mean: 80,
|
|
minValue: 80,
|
|
maxValue: 80,
|
|
sumValue: 80,
|
|
sd: 0,
|
|
slope: null,
|
|
r2: null,
|
|
computedAt: new Date(),
|
|
},
|
|
],
|
|
});
|
|
peerInserted();
|
|
await gate;
|
|
},
|
|
{ maxWait: 10_000, timeout: 30_000 },
|
|
);
|
|
await peerReady;
|
|
|
|
// Second measurement lands (committed) — the write hook's aggregate
|
|
// sees BOTH rows: count 2, mean 90.
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 100,
|
|
unit: "kg",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: m2At,
|
|
},
|
|
});
|
|
|
|
const hook = recomputeBucketsForMeasurement(user.id, "WEIGHT", m1At);
|
|
|
|
// Let the hook's persist reach the PK wait behind the peer's
|
|
// uncommitted row, then commit the peer with its STALE aggregate.
|
|
await waitForRollupLockWaiter();
|
|
releasePeer();
|
|
await peer;
|
|
await hook;
|
|
|
|
const row = await prisma.measurementRollup.findUnique({
|
|
where: {
|
|
userId_type_granularity_bucketStart_source: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
granularity: "DAY",
|
|
bucketStart: dayStart,
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
},
|
|
});
|
|
// The hook computed from the full day (both measurements). Its
|
|
// recompute must survive the race — a swallowed unique violation
|
|
// would leave the peer's stale count-1 / mean-80 aggregate behind.
|
|
expect(row).not.toBeNull();
|
|
expect(row!.count).toBe(2);
|
|
expect(row!.mean).toBeCloseTo(90, 6);
|
|
expect(row!.minValue).toBeCloseTo(80, 6);
|
|
expect(row!.maxValue).toBeCloseTo(100, 6);
|
|
expect(row!.sumValue).toBeCloseTo(180, 6);
|
|
});
|
|
|
|
it("two full user folds racing neither throw nor duplicate rows", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "rollup-fold-race-user",
|
|
email: "rollup-fold-race-user@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
const days = 30;
|
|
const data = Array.from({ length: days }, (_, i) => ({
|
|
userId: user.id,
|
|
type: "WEIGHT" as const,
|
|
value: 80 + (i % 5),
|
|
unit: "kg",
|
|
source: "APPLE_HEALTH" as const,
|
|
measuredAt: new Date(Date.UTC(2026, 3, 1 + i, 8, 0, 0)),
|
|
}));
|
|
await prisma.measurement.createMany({ data });
|
|
|
|
const window = {
|
|
from: new Date("2026-03-31T00:00:00.000Z"),
|
|
to: new Date("2026-05-02T00:00:00.000Z"),
|
|
};
|
|
// The end-of-import fold racing the boot backfill for the same user.
|
|
await expect(
|
|
Promise.all([
|
|
recomputeUserRollups(user.id, window),
|
|
recomputeUserRollups(user.id, window),
|
|
]),
|
|
).resolves.toBeDefined();
|
|
|
|
const rows = await prisma.measurementRollup.findMany({
|
|
where: { userId: user.id, type: "WEIGHT", granularity: "DAY" },
|
|
});
|
|
expect(rows).toHaveLength(days);
|
|
for (const row of rows) {
|
|
expect(row.count).toBe(1);
|
|
}
|
|
});
|
|
|
|
it("N concurrent write-hook recomputes for the same day all resolve", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "rollup-fanout-user",
|
|
email: "rollup-fanout-user@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
const measuredAt = new Date("2026-04-22T12:00:00.000Z");
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: new Date("2026-04-22T07:00:00.000Z"),
|
|
},
|
|
{
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 84,
|
|
unit: "kg",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: new Date("2026-04-22T20:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
Promise.all(
|
|
Array.from({ length: 8 }, () =>
|
|
recomputeBucketsForMeasurement(user.id, "WEIGHT", measuredAt),
|
|
),
|
|
),
|
|
).resolves.toBeDefined();
|
|
|
|
const row = await prisma.measurementRollup.findUnique({
|
|
where: {
|
|
userId_type_granularity_bucketStart_source: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
granularity: "DAY",
|
|
bucketStart: new Date("2026-04-22T00:00:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
},
|
|
});
|
|
expect(row).not.toBeNull();
|
|
expect(row!.count).toBe(2);
|
|
expect(row!.mean).toBeCloseTo(82, 6);
|
|
});
|
|
});
|