Files
HealthLog/src/lib/measurements/rollups.ts
T
Marc-André Bombeck 6cbdad5f58 perf(rollups): parallel WEEK/MONTH/YEAR enqueue on measurement write
`recomputeBucketsForMeasurement` enqueued the three async-granularity
recomputes in a serial `for-of` loop. Each enqueue is independent
(different pg-boss singletonKey per granularity) so the await chain
adds three sequential round-trips to the post-write critical path
on every measurement create / update / delete. Replace the loop with
`Promise.all` so the three enqueues fan out at once. Matters on iOS
batch imports that fire hundreds of hooks back-to-back.
2026-05-17 17:22:10 +02:00

796 lines
28 KiB
TypeScript

/**
* v1.5.0 — persistent measurement-rollup populator.
*
* Sits behind the 60-s in-process LRU as the second cache tier
* (per `.planning/research/v1434-r-cache-aggregation.md` §9). One row
* per `(userId, type, granularity, bucketStart)` in
* `measurement_rollups`, carrying the count / min / max / mean / sd /
* slope / r2 stats Postgres folds via `STDDEV_POP` / `REGR_SLOPE` /
* `REGR_R2`. The stats semantics mirror the v1.4.34.1 comprehensive
* aggregator so cached + live aggregates agree byte-for-byte.
*
* Write hooks:
* - The measurement create / update / delete endpoints call
* `recomputeBucketsForMeasurement` after their successful commit.
* The DAY bucket recompute runs inline (cheap, scoped to a single
* `(user, type, day)` tuple) so the next read is correct.
* WEEK / MONTH / YEAR recomputes enqueue onto pg-boss for the
* worker to fold.
* - The Apple Health import worker calls `recomputeUserRollups`
* ONCE at the end of the import, scoped to the import's date
* range, to avoid per-row write-hook overhead on 100k-row
* backfills.
*
* Reads:
* - `readRollupBuckets` returns the persisted rows. The reader
* side (`analytics`, `comprehensive`, `summaries-slice`) calls
* `isRollupFresh` first to decide whether to fall through to the
* live aggregator + persist-on-miss. Cheap heuristic — one
* indexed query for the newest measurement's `measuredAt`.
*/
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import type {
MeasurementType,
RollupGranularity,
} from "@/generated/prisma/client";
/** All granularities the rollup table tracks. */
export const ALL_GRANULARITIES: RollupGranularity[] = [
"DAY",
"WEEK",
"MONTH",
"YEAR",
];
/** Granularities that recompute asynchronously via pg-boss. */
const ASYNC_GRANULARITIES: RollupGranularity[] = ["WEEK", "MONTH", "YEAR"];
/** pg-boss queue name. */
export const ROLLUP_RECOMPUTE_QUEUE = "rollup-recompute";
/** Worker concurrency cap — recomputes are read-heavy on Postgres. */
export const ROLLUP_RECOMPUTE_CONCURRENCY = 2;
/**
* Payload `boss.send` carries onto the `rollup-recompute` queue. The
* worker hands the values straight to `recomputeUserRollups`.
*/
export interface RollupRecomputePayload {
userId: string;
type: MeasurementType;
granularity: RollupGranularity;
/** ISO-8601 string — the worker parses through `new Date(value)`. */
from: string;
/** ISO-8601 string — the worker parses through `new Date(value)`. */
to: string;
/** Wall-clock kick-off for debugging only. */
enqueuedAt: string;
}
/**
* v1.4.35.1 — boot-time full-fold queue. The write-path hooks keep
* the DAY bucket current per measurement, and `ensureUserRollupsFresh`
* warm-on-read covers the trailing 90-day DAY window on cold mount,
* but neither path folds the deep WEEK / MONTH / YEAR history for an
* account that has measurements pre-dating the v1.4.35 deploy. This
* queue exists so the worker boot can enqueue one full
* `recomputeUserRollups(userId)` per uncovered user — no operator
* action needed on self-hosted instances.
*/
export const ROLLUP_FULL_BACKFILL_QUEUE = "rollup-full-backfill";
/** Boot-fold worker concurrency. Serial so we don't crowd the pool. */
export const ROLLUP_FULL_BACKFILL_CONCURRENCY = 1;
/** Payload the boot-time backfill helper enqueues. */
export interface RollupFullBackfillPayload {
userId: string;
/** Wall-clock kick-off for debugging only. */
enqueuedAt: string;
}
/** Postgres `date_trunc` unit per granularity. */
const DATE_TRUNC_UNIT: Record<RollupGranularity, string> = {
DAY: "day",
WEEK: "week",
MONTH: "month",
YEAR: "year",
};
/** Raw row shape returned by the aggregate `$queryRaw`. */
interface RollupRow {
type: string;
bucket_start: Date;
count: bigint;
mean: number | null;
min_value: number | null;
max_value: number | null;
sd: number | null;
slope: number | null;
r2: number | null;
}
/**
* Recompute the rollup buckets for one user. Bounded by the optional
* `types` / `granularities` / `from` / `to` filters; defaults cover
* every type, every granularity, and the trailing 5 years.
*
* Idempotent: rows are upserted under the
* `(userId, type, granularity, bucketStart)` composite primary key,
* so re-running the call is a no-op for unchanged buckets and a
* refresh for changed ones.
*/
export async function recomputeUserRollups(
userId: string,
opts: {
types?: MeasurementType[];
granularities?: RollupGranularity[];
from?: Date;
to?: Date;
} = {},
): Promise<{ rowsUpserted: number; durationMs: number }> {
const startedAt = Date.now();
const granularities = opts.granularities ?? ALL_GRANULARITIES;
const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
const from = opts.from ?? fiveYearsAgo;
const to = opts.to ?? new Date();
let rowsUpserted = 0;
for (const granularity of granularities) {
const rows = await runRollupAggregate({
userId,
types: opts.types,
granularity,
from,
to,
});
if (rows.length === 0) continue;
rowsUpserted += await persistRollupRows(userId, granularity, rows);
}
return { rowsUpserted, durationMs: Date.now() - startedAt };
}
/**
* Synchronous DAY bucket recompute for the write-path hooks plus an
* async enqueue of the WEEK / MONTH / YEAR recomputes. The DAY pass
* runs inline so the next read of `comprehensive` / `analytics`
* reflects the new row without waiting on pg-boss.
*
* Scoped to the affected `(userId, type, day)` tuple — cheap, and
* avoids invalidating buckets that haven't changed.
*/
export async function recomputeBucketsForMeasurement(
userId: string,
type: MeasurementType,
measuredAt: Date,
): Promise<void> {
// DAY pass — synchronous. Pull the rollup for the affected day
// and upsert it. The aggregate is scoped to the single day so the
// SQL is bounded even for users with multi-year history.
const dayStart = startOfUtcDay(measuredAt);
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
const rows = await runRollupAggregate({
userId,
types: [type],
granularity: "DAY",
from: dayStart,
to: dayEnd,
});
if (rows.length === 0) {
// No rows in this day after the mutation (e.g. the DELETE path
// removed the last reading) — drop the rollup row if it exists.
await prisma.measurementRollup.deleteMany({
where: {
userId,
type,
granularity: "DAY",
bucketStart: dayStart,
},
});
} else {
await persistRollupRows(userId, "DAY", rows);
}
// WEEK / MONTH / YEAR — enqueue. Each granularity covers a range
// around the affected day so the worker only re-aggregates the
// bucket(s) the write could have changed.
//
// v1.4.38 — the three `enqueueRollupRecompute` calls are independent
// (different singletonKey per granularity inside `enqueueRollupRecompute`)
// so fan them out via `Promise.all` instead of sequencing them with
// `await`. Halves the post-write critical path on every measurement
// create / update / delete; matters on iOS batch imports that fire
// hundreds of hooks back-to-back.
await Promise.all(
ASYNC_GRANULARITIES.map((granularity) => {
const { from, to } = bucketSpan(measuredAt, granularity);
return enqueueRollupRecompute({
userId,
type,
granularity,
from,
to,
});
}),
);
}
/**
* Best-effort pg-boss enqueue. Silent no-op when no boss is attached
* (route is running in a context without the worker — typically the
* test suite). The reader-side fall-through still produces fresh
* data on a cache miss, so missing the enqueue is at-most a one-read
* cost rather than a correctness bug.
*/
export async function enqueueRollupRecompute(input: {
userId: string;
type: MeasurementType;
granularity: RollupGranularity;
from: Date;
to: Date;
}): Promise<void> {
const boss = getGlobalBoss();
if (!boss) return;
const payload: RollupRecomputePayload = {
userId: input.userId,
type: input.type,
granularity: input.granularity,
from: input.from.toISOString(),
to: input.to.toISOString(),
enqueuedAt: new Date().toISOString(),
};
await boss.send(ROLLUP_RECOMPUTE_QUEUE, payload, {
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
// Coalesce duplicate enqueues for the same bucket key — multiple
// measurements landing on the same day shouldn't each spawn a
// separate worker run. The singleton key uses bucket-start so
// two writes inside the same week share one queued job.
singletonKey: `${input.userId}|${input.type}|${input.granularity}|${input.from.toISOString()}`,
});
}
/**
* Read rollup rows for `(userId, type, granularity)` in `[from, to)`.
* Returns rows sorted ascending by `bucketStart`.
*/
export async function readRollupBuckets(
userId: string,
type: MeasurementType,
granularity: RollupGranularity,
from: Date,
to: Date,
): Promise<
Array<{
bucketStart: Date;
count: number;
mean: number;
minValue: number;
maxValue: number;
sd: number | null;
slope: number | null;
r2: number | null;
computedAt: Date;
}>
> {
const rows = await prisma.measurementRollup.findMany({
where: {
userId,
type,
granularity,
bucketStart: { gte: from, lt: to },
},
orderBy: { bucketStart: "asc" },
});
return rows.map((r) => ({
bucketStart: r.bucketStart,
count: r.count,
mean: r.mean,
minValue: r.minValue,
maxValue: r.maxValue,
sd: r.sd,
slope: r.slope,
r2: r.r2,
computedAt: r.computedAt,
}));
}
/**
* Cheap freshness heuristic — one indexed query against
* `measurements` for the newest row per (user, type). Returns true
* when the persisted rollup covers every measurement currently in
* the DB, false when there's a newer measurement than the freshest
* rollup row (the rollup needs a recompute).
*
* `null` newest measurement means the user has no rows of this type,
* in which case the rollups are trivially fresh (empty == empty).
*/
export async function isRollupFresh(
userId: string,
type: MeasurementType,
granularity: RollupGranularity,
newestComputedAt: Date | null,
): Promise<boolean> {
const newest = await prisma.measurement.findFirst({
where: { userId, type },
orderBy: { measuredAt: "desc" },
select: { measuredAt: true, updatedAt: true },
});
if (!newest) {
// No measurements at all — empty rollup set is fresh by definition.
return true;
}
if (!newestComputedAt) {
// We have measurements but no rollup rows — definitely stale.
return false;
}
// Stale when the latest row was written / updated after the most
// recent rollup `computed_at` for this `(user, type, granularity)`.
const watermark =
newest.updatedAt > newest.measuredAt ? newest.updatedAt : newest.measuredAt;
return watermark <= newestComputedAt;
}
// ─── internals ────────────────────────────────────────────
/**
* Pull the raw aggregates from Postgres. Groups by
* `date_trunc(<granularity>, measured_at)` and folds the stats with
* `STDDEV_POP` / `REGR_SLOPE` / `REGR_R2`. The slope X-axis is days
* (epoch / 86400) so the slope is in "units per day", matching the
* comprehensive-aggregator + summaries-slice convention.
*/
async function runRollupAggregate(input: {
userId: string;
types: MeasurementType[] | undefined;
granularity: RollupGranularity;
from: Date;
to: Date;
}): Promise<RollupRow[]> {
if (input.types && input.types.length === 0) return [];
// Postgres rejects `date_trunc($1::text, m."measured_at")` in a
// GROUP BY because the planner sees the parameterised expression
// and can't match the SELECT-list to the GROUP-BY expression. The
// unit literal must be inlined. The value comes from a closed enum
// (DATE_TRUNC_UNIT) so there's no injection surface — we still
// assert against the whitelist before splicing.
const truncUnit = DATE_TRUNC_UNIT[input.granularity];
if (!["day", "week", "month", "year"].includes(truncUnit)) {
throw new Error(`unexpected granularity: ${input.granularity}`);
}
const dateTrunc = `date_trunc('${truncUnit}', m."measured_at")`;
if (input.types && input.types.length > 0) {
// Build the type-list literal carefully. The enum values come
// from `MeasurementType` (Prisma-generated TS enum); we whitelist
// them with a strict regex before splicing into SQL.
for (const t of input.types) {
if (!/^[A-Z_]+$/.test(t)) {
throw new Error(`invalid measurement type: ${t}`);
}
}
const typeList = input.types
.map((t) => `'${t}'::"measurement_type"`)
.join(",");
const sql = `
SELECT
m."type"::text AS type,
${dateTrunc} AS bucket_start,
COUNT(*) AS count,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
STDDEV_POP(m."value")::double precision AS sd,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
)::double precision AS slope,
REGR_R2(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
)::double precision AS r2
FROM measurements m
WHERE m."user_id" = $1
AND m."type" IN (${typeList})
AND m."measured_at" >= $2
AND m."measured_at" < $3
GROUP BY m."type", ${dateTrunc}
`;
return prisma.$queryRawUnsafe<RollupRow[]>(
sql,
input.userId,
input.from,
input.to,
);
}
const sql = `
SELECT
m."type"::text AS type,
${dateTrunc} AS bucket_start,
COUNT(*) AS count,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
STDDEV_POP(m."value")::double precision AS sd,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
)::double precision AS slope,
REGR_R2(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
)::double precision AS r2
FROM measurements m
WHERE m."user_id" = $1
AND m."measured_at" >= $2
AND m."measured_at" < $3
GROUP BY m."type", ${dateTrunc}
`;
return prisma.$queryRawUnsafe<RollupRow[]>(
sql,
input.userId,
input.from,
input.to,
);
}
/**
* UPSERT the aggregate rows into `measurement_rollups`. Returns the
* number of rows touched. Uses a single chunked `$executeRaw` insert
* with `ON CONFLICT DO UPDATE` so the round-trip count is bounded
* regardless of fan-out width.
*/
async function persistRollupRows(
userId: string,
granularity: RollupGranularity,
rows: RollupRow[],
): Promise<number> {
if (rows.length === 0) return 0;
// Chunk to keep the parameter count below Postgres' 65k cap and
// the transaction round-trip count bounded for large backfills.
// For the typical hot path (a single DAY recompute = 1 row, a
// multi-week WEEK recompute = a few rows) the round-trip count is
// negligible.
const CHUNK = 500;
let touched = 0;
for (let i = 0; i < rows.length; i += CHUNK) {
const slice = rows.slice(i, i + CHUNK);
await prisma.$transaction(
slice.map((row) =>
prisma.measurementRollup.upsert({
where: {
userId_type_granularity_bucketStart: {
userId,
type: row.type as MeasurementType,
granularity,
bucketStart: row.bucket_start,
},
},
create: {
userId,
type: row.type as MeasurementType,
granularity,
bucketStart: row.bucket_start,
count: Number(row.count),
mean: row.mean ?? 0,
minValue: row.min_value ?? 0,
maxValue: row.max_value ?? 0,
sd: row.sd,
slope: row.slope,
r2: row.r2,
computedAt: new Date(),
},
update: {
count: Number(row.count),
mean: row.mean ?? 0,
minValue: row.min_value ?? 0,
maxValue: row.max_value ?? 0,
sd: row.sd,
slope: row.slope,
r2: row.r2,
computedAt: new Date(),
},
}),
),
);
touched += slice.length;
}
return touched;
}
/**
* Truncate a `Date` to the start of its UTC day. The rollup table
* stores TIMESTAMPTZ values so we anchor on UTC to keep the bucket
* key deterministic across timezone-shifting consumers.
*/
function startOfUtcDay(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0),
);
}
/**
* Span of the bucket containing `measuredAt` at the given granularity.
* Postgres `date_trunc` is the canonical truncator; we mirror its
* Monday-anchored ISO week and calendar-month / calendar-year
* semantics in JS so the worker re-aggregation queries select the
* same set of rows the read-side `date_trunc` would group on.
*/
function bucketSpan(
measuredAt: Date,
granularity: RollupGranularity,
): { from: Date; to: Date } {
switch (granularity) {
case "DAY": {
const from = startOfUtcDay(measuredAt);
return { from, to: new Date(from.getTime() + 24 * 60 * 60 * 1000) };
}
case "WEEK": {
const day = startOfUtcDay(measuredAt);
// Postgres ISO week: Monday is day 1. JS getUTCDay(): Sunday=0..Saturday=6.
const dayOfWeek = day.getUTCDay(); // 0=Sun
const mondayOffset = (dayOfWeek + 6) % 7; // 0 for Mon … 6 for Sun
const from = new Date(
day.getTime() - mondayOffset * 24 * 60 * 60 * 1000,
);
return { from, to: new Date(from.getTime() + 7 * 24 * 60 * 60 * 1000) };
}
case "MONTH": {
const from = new Date(
Date.UTC(measuredAt.getUTCFullYear(), measuredAt.getUTCMonth(), 1),
);
const to = new Date(
Date.UTC(
measuredAt.getUTCFullYear(),
measuredAt.getUTCMonth() + 1,
1,
),
);
return { from, to };
}
case "YEAR": {
const from = new Date(Date.UTC(measuredAt.getUTCFullYear(), 0, 1));
const to = new Date(Date.UTC(measuredAt.getUTCFullYear() + 1, 0, 1));
return { from, to };
}
}
}
/**
* v1.4.38 — per-userId in-flight dedup. v1.4.37.1 fire-and-forgets the
* `ensureUserRollupsFresh` call from the read path so a stale 90-day
* recompute can no longer stall the response, but if a request DOES
* end up awaiting a synchronous recompute (worker code path, future
* caller without the fire-and-forget) and the same user has a cold
* fan-out hitting analytics + comprehensive + summaries within the
* same tick, the legacy shape would queue N parallel
* `recomputeUserRollups` invocations against the same 90-day window.
* The map below collapses those into one shared promise — every
* caller after the first re-uses the in-flight resolution and only
* one Postgres aggregate runs. Mirrors the `buildCoachSnapshot` dedup
* pattern that lives one layer up. Cleared in `finally` so a slow
* promise that eventually rejects does not poison the next request.
*/
const ensureUserRollupsFreshInFlight = new Map<
string,
Promise<{ recomputed: boolean }>
>();
/**
* Cheap read-side warm-up. Called from the analytics + comprehensive
* read paths so the rollup table stays current for downstream
* consumers without each read paying the full live-aggregation cost
* twice.
*
* Contract:
* - If the user has at least one DAY rollup row that is current
* (newest measurement ≤ newest rollup `computed_at`), this is a
* no-op.
* - Otherwise the function kicks off a `recomputeUserRollups` over
* the trailing 90-day window (the dashboard hot path) for the
* DAY granularity only. WEEK / MONTH / YEAR follow on the next
* write hook or the explicit backfill — we don't pay for them
* here.
* - Best-effort: thrown errors are swallowed so the read path
* never fails because of a populator hiccup.
* - v1.4.38: concurrent callers for the same `userId` share one
* in-flight promise so a cold fan-out can never queue N parallel
* `recomputeUserRollups` runs against the same 90-day window.
*/
export async function ensureUserRollupsFresh(
userId: string,
): Promise<{ recomputed: boolean }> {
// v1.4.38 — share the in-flight promise across concurrent callers
// for the same userId. The map is keyed on `userId` because the
// helper has no other parameters; the next caller hits the
// resolved promise as soon as it lands. We delete the slot in
// `finally` so a slow rejected promise does not stay cached and
// poison the next request.
const existing = ensureUserRollupsFreshInFlight.get(userId);
if (existing) return existing;
const inflight = ensureUserRollupsFreshImpl(userId).finally(() => {
ensureUserRollupsFreshInFlight.delete(userId);
});
ensureUserRollupsFreshInFlight.set(userId, inflight);
return inflight;
}
/**
* Test-only handle on the in-flight map. Mirrors the
* `_resetTrustViolationWarningForTests` pattern in api-response.ts —
* production code never calls this.
*/
export function _resetEnsureUserRollupsFreshInFlightForTests(): void {
ensureUserRollupsFreshInFlight.clear();
}
async function ensureUserRollupsFreshImpl(
userId: string,
): Promise<{ recomputed: boolean }> {
try {
// Single round-trip — the most-recent DAY rollup for the user
// and the most-recent measurement updatedAt. If the rollup is
// ahead of the measurement, we're done.
const [newestRollup, newestMeasurement] = await Promise.all([
prisma.measurementRollup.findFirst({
where: { userId, granularity: "DAY" },
orderBy: { computedAt: "desc" },
select: { computedAt: true },
}),
prisma.measurement.findFirst({
where: { userId },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true, measuredAt: true },
}),
]);
if (!newestMeasurement) {
// User has no measurements — rollups are trivially fresh.
return { recomputed: false };
}
const watermark =
newestMeasurement.updatedAt > newestMeasurement.measuredAt
? newestMeasurement.updatedAt
: newestMeasurement.measuredAt;
if (newestRollup && watermark <= newestRollup.computedAt) {
return { recomputed: false };
}
// Stale — refresh the trailing 90-day DAY window. Synchronous so
// the next read of `comprehensive` / `analytics` benefits even on
// the first cold-mount fan-out. Bounded to DAY granularity so the
// read path doesn't accidentally pay the WEEK / MONTH / YEAR
// cost; those are handled by write hooks + the backfill script.
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
await recomputeUserRollups(userId, {
granularities: ["DAY"],
from: ninetyDaysAgo,
to: new Date(),
});
return { recomputed: true };
} catch (err) {
// Best-effort — never block the read path on a populator failure.
// v1.4.36 QA H3 — surface the failure so ops can spot a silent
// populator regression. `annotate` is a no-op when there's no
// request context (worker calls, tests); `console.error` carries
// the same shape through the worker log pipeline so the regression
// is visible regardless of caller surface.
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
rollup_refresh_failed: true,
rollup_refresh_error: message,
},
});
console.error("[rollups] ensureUserRollupsFresh failed:", message);
return { recomputed: false };
}
}
/**
* v1.4.35.1 — boot-time enqueue helper. Finds users with measurements
* but zero rollup coverage and enqueues one full-fold job per account
* onto `ROLLUP_FULL_BACKFILL_QUEUE`. Designed to be called once at
* worker boot so every self-hosted instance auto-converges on the
* persistent rollup tier without operator intervention.
*
* Idempotent across reboots:
* - the discovery query only matches accounts with no rollup rows at
* all, so once the fold completes the user drops off the list and
* subsequent boots skip them
* - pg-boss `singletonKey` coalesces duplicate sends within the
* queue, so a fast restart while a backfill is queued doesn't
* double up
*
* Best-effort: errors are swallowed and reported through the return
* value so the worker boot never fails because of a backfill miss.
*/
export async function enqueueBootTimeRollupBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
// Users with at least one measurement but no rollup row yet. The
// `LIMIT 1` inside each `EXISTS` keeps the planner from materialising
// the full child relation just to answer existence — important on
// power-user accounts with 100k+ measurements.
const users = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT u."id"
FROM users u
WHERE EXISTS (
SELECT 1 FROM measurements m WHERE m."user_id" = u."id" LIMIT 1
)
AND NOT EXISTS (
SELECT 1 FROM measurement_rollups r WHERE r."user_id" = u."id" LIMIT 1
)
`;
if (users.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { id } of users) {
const payload: RollupFullBackfillPayload = {
userId: id,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(ROLLUP_FULL_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
// Coalesce: if a backfill for this user is already queued and
// we restart, pg-boss returns null instead of duplicating.
singletonKey: `boot-backfill|${id}`,
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Collapse a list of `(type, day)` keys to the set of distinct
* `(type, dayStart)` pairs. Used by the batch-ingest hooks so a 500-
* row batch fires at most N DAY recomputes (typically <10 for a
* single-day batch).
*/
export function collapseToTypeDayKeys(
rows: Array<{ type: MeasurementType; measuredAt: Date }>,
): Array<{ type: MeasurementType; measuredAt: Date }> {
const seen = new Map<string, { type: MeasurementType; measuredAt: Date }>();
for (const row of rows) {
const dayStart = startOfUtcDay(row.measuredAt);
const key = `${row.type}|${dayStart.toISOString()}`;
if (!seen.has(key)) {
seen.set(key, { type: row.type, measuredAt: dayStart });
}
}
return Array.from(seen.values());
}