mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(measurements): stop the cumulative drain aborting on a poisoned day
The cumulative pass lacked the per-day error boundary and adopt-in-place canonical-noon resolution its sibling drains carry, so the first day whose local-noon mint collided with the (measured_at, source, sleep_stage) unique index threw and aborted the entire global walk — leaving years of raw step/distance rows uncollapsed. Resolve the canonical daily row by externalId then by the canonical-noon slot and adopt it in place, retry once on a concurrent-writer P2002, and route per-day failures through onBucketError so one bad day is stepped over while every other day still collapses.
This commit is contained in:
1 parent
cf903f5022
commit
4851f6534b
2 files changed
+317
-105
No files matched your search
@@ -342,16 +342,18 @@ describe("drainPerSampleCumulative — cutoffHours", () => {
|
||||
|
||||
// A1 — a late watch sync after a day's total was already collapsed must
|
||||
// FOLD the new per-sample rows into the existing total, never overwrite
|
||||
// it with just the partial late sum (the original samples are gone).
|
||||
// it with just the partial late sum (the original samples are gone). The
|
||||
// adopt-in-place write resolves the canonical row by the index-A `stats:`
|
||||
// externalId first, then adopts it in place (`update`) or mints a fresh row
|
||||
// (`create`).
|
||||
describe("drainPerSampleCumulative — late-sync fold into existing total", () => {
|
||||
function buildFoldMock(existingValue: number | null) {
|
||||
function buildFoldMock(existingTotal: number | null) {
|
||||
const findManyUser = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]);
|
||||
|
||||
// Only ACTIVITY_STEPS yields the late per-sample rows; every other
|
||||
// cumulative type returns an empty scan so the run stays a single
|
||||
// bucket.
|
||||
// cumulative type returns an empty scan so the run stays a single bucket.
|
||||
const findManyMeasurement = vi.fn(
|
||||
async (args: { where: { type: string } }) => {
|
||||
if (args.where.type === "ACTIVITY_STEPS") {
|
||||
@@ -376,18 +378,32 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () =
|
||||
},
|
||||
);
|
||||
|
||||
const upsert = vi.fn().mockResolvedValue({});
|
||||
const update = vi.fn().mockResolvedValue({ id: "stats-row" });
|
||||
const create = vi.fn().mockResolvedValue({ id: "stats-row-new" });
|
||||
const deleteMany = vi.fn().mockResolvedValue({ count: 2 });
|
||||
const findUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
existingValue === null ? null : { value: existingValue },
|
||||
);
|
||||
const findFirst = vi.fn().mockResolvedValue({ unit: "count" });
|
||||
|
||||
const tx = {
|
||||
measurement: { upsert, deleteMany, findUnique, findFirst },
|
||||
};
|
||||
// `findFirst` serves three roles inside `writeDay`:
|
||||
// 1. index-A lookup (filters on `externalId`) → the existing collapsed
|
||||
// total when one exists.
|
||||
// 2. index-B slot lookup (filters on `measuredAt`) → null here (no row
|
||||
// sits on the canonical instant).
|
||||
// 3. `resolveCanonicalUnit` (filters on neither, selects `unit`) → unit.
|
||||
const findFirst = vi.fn(
|
||||
async (args: {
|
||||
where: Record<string, unknown>;
|
||||
select?: Record<string, unknown>;
|
||||
}) => {
|
||||
if ("externalId" in args.where) {
|
||||
return existingTotal === null
|
||||
? null
|
||||
: { id: "stats-row", value: existingTotal };
|
||||
}
|
||||
if ("measuredAt" in args.where) return null; // no index-B collision
|
||||
return { unit: "count" }; // resolveCanonicalUnit
|
||||
},
|
||||
);
|
||||
|
||||
const tx = { measurement: { update, create, deleteMany, findFirst } };
|
||||
|
||||
return {
|
||||
prisma: {
|
||||
@@ -397,39 +413,173 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () =
|
||||
cb(tx),
|
||||
),
|
||||
} as unknown as PrismaClient,
|
||||
upsert,
|
||||
findUnique,
|
||||
update,
|
||||
create,
|
||||
};
|
||||
}
|
||||
|
||||
it("folds late samples into a pre-existing collapsed total (no shrink)", async () => {
|
||||
// Day already collapsed to 9000 on an earlier run; a late sync of
|
||||
// 300 + 200 = 500 must take the row to 9500, not down to 500.
|
||||
const { prisma, upsert, findUnique } = buildFoldMock(9000);
|
||||
const { prisma, update, create } = buildFoldMock(9000);
|
||||
|
||||
await drainPerSampleCumulative(prisma, { log: () => {} });
|
||||
|
||||
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
const upsertArg = upsert.mock.calls[0]?.[0] as {
|
||||
update: { value: number };
|
||||
};
|
||||
expect(upsertArg.update.value).toBe(9500);
|
||||
// The index-A row is adopted in place — updated, never re-created.
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const updateArg = update.mock.calls[0]?.[0] as { data: { value: number } };
|
||||
expect(updateArg.data.value).toBe(9500);
|
||||
});
|
||||
|
||||
it("mints the partial sum when no collapsed total exists yet", async () => {
|
||||
const { prisma, upsert } = buildFoldMock(null);
|
||||
const { prisma, update, create } = buildFoldMock(null);
|
||||
|
||||
await drainPerSampleCumulative(prisma, { log: () => {} });
|
||||
|
||||
const upsertArg = upsert.mock.calls[0]?.[0] as {
|
||||
create: { value: number };
|
||||
update: { value: number };
|
||||
// Fresh day: no canonical row to adopt → a new row is created with the
|
||||
// raw sum.
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
const createArg = create.mock.calls[0]?.[0] as { data: { value: number } };
|
||||
expect(createArg.data.value).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// Root cause of the v1.18.10 bloat: a single day whose mint collided with the
|
||||
// canonical-noon unique index threw and aborted the ENTIRE global walk (3 s
|
||||
// die-early, 0 collapsed). The per-day error boundary must step the poisoned
|
||||
// day over so every other day still collapses; the adopt-in-place path must
|
||||
// absorb an index-B slot collision rather than throw at all.
|
||||
describe("drainPerSampleCumulative — colliding day does not abort the walk", () => {
|
||||
it("steps over a poisoned day and still collapses the others", async () => {
|
||||
const findManyUser = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]);
|
||||
|
||||
// Two ACTIVITY_STEPS days: 2026-05-16 (poison) and 2026-05-17 (clean).
|
||||
const findManyMeasurement = vi.fn(
|
||||
async (args: { where: { type: string } }) => {
|
||||
if (args.where.type === "ACTIVITY_STEPS") {
|
||||
return [
|
||||
{
|
||||
id: "d16-a",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1000,
|
||||
measuredAt: new Date("2026-05-16T09:00:00.000Z"),
|
||||
externalId: "hk-uuid-16a",
|
||||
},
|
||||
{
|
||||
id: "d17-a",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 2000,
|
||||
measuredAt: new Date("2026-05-17T09:00:00.000Z"),
|
||||
externalId: "hk-uuid-17a",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
const create = vi.fn(async (args: { data: { measuredAt: Date } }) => {
|
||||
// The 2026-05-16 day mint throws a NON-P2002 error on every attempt —
|
||||
// a genuinely poisoned day the adopt path can't recover. Berlin local
|
||||
// noon on 2026-05-16 (CEST) is 10:00 UTC.
|
||||
const iso = args.data.measuredAt.toISOString();
|
||||
if (iso === "2026-05-16T10:00:00.000Z") {
|
||||
throw new Error("simulated poison-day write failure");
|
||||
}
|
||||
return { id: "stats-row-new" };
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({ id: "stats-row" });
|
||||
const deleteMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const findFirst = vi.fn(
|
||||
async (args: { where: Record<string, unknown> }) => {
|
||||
if ("externalId" in args.where) return null; // no existing total
|
||||
if ("measuredAt" in args.where) return null; // no index-B collision
|
||||
return { unit: "count" };
|
||||
},
|
||||
);
|
||||
|
||||
const tx = { measurement: { update, create, deleteMany, findFirst } };
|
||||
const prisma = {
|
||||
user: { findMany: findManyUser },
|
||||
measurement: { findMany: findManyMeasurement },
|
||||
$transaction: vi.fn(async (cb: (t: typeof tx) => Promise<unknown>) =>
|
||||
cb(tx),
|
||||
),
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const summary = await drainPerSampleCumulative(prisma, { log: () => {} });
|
||||
|
||||
// The poisoned day is stepped over, NOT rethrown; the clean day collapses.
|
||||
expect(summary.totals.daysFailed).toBe(1);
|
||||
expect(summary.totals.bucketsCollapsed).toBe(1);
|
||||
expect(summary.totals.usersScanned).toBe(1);
|
||||
// The clean 2026-05-17 mint went through.
|
||||
expect(create).toHaveBeenCalledTimes(2); // both days attempt a create
|
||||
});
|
||||
|
||||
it("adopts an index-B canonical-noon row in place instead of colliding", async () => {
|
||||
const findManyUser = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]);
|
||||
|
||||
const findManyMeasurement = vi.fn(
|
||||
async (args: { where: { type: string } }) => {
|
||||
if (args.where.type === "ACTIVITY_STEPS") {
|
||||
return [
|
||||
{
|
||||
id: "samp-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1500,
|
||||
measuredAt: new Date("2026-05-16T09:00:00.000Z"),
|
||||
externalId: "hk-uuid-1",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
const update = vi.fn().mockResolvedValue({ id: "slot-row" });
|
||||
const create = vi.fn().mockResolvedValue({ id: "should-not-be-called" });
|
||||
const deleteMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
// No index-A stats row, but a DIFFERENT row already sits on the canonical
|
||||
// local-noon instant (index B) — e.g. a manual daily entry. The drain must
|
||||
// adopt it in place, never `create` (which would P2002 against index B).
|
||||
const findFirst = vi.fn(
|
||||
async (args: { where: Record<string, unknown> }) => {
|
||||
if ("externalId" in args.where) return null;
|
||||
if ("measuredAt" in args.where) return { id: "slot-row" };
|
||||
return { unit: "count" };
|
||||
},
|
||||
);
|
||||
|
||||
const tx = { measurement: { update, create, deleteMany, findFirst } };
|
||||
const prisma = {
|
||||
user: { findMany: findManyUser },
|
||||
measurement: { findMany: findManyMeasurement },
|
||||
$transaction: vi.fn(async (cb: (t: typeof tx) => Promise<unknown>) =>
|
||||
cb(tx),
|
||||
),
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const summary = await drainPerSampleCumulative(prisma, { log: () => {} });
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const updateArg = update.mock.calls[0]?.[0] as {
|
||||
where: { id: string };
|
||||
data: { externalId: string; value: number };
|
||||
};
|
||||
// Fresh day: create branch carries the raw sum; update branch (taken
|
||||
// on an idempotent re-run with no prior total) also resolves to the
|
||||
// raw sum since `existing` was null.
|
||||
expect(upsertArg.create.value).toBe(500);
|
||||
expect(upsertArg.update.value).toBe(500);
|
||||
// The slot row is adopted: stamped with the target stats externalId and
|
||||
// the bucket's value, no collision, no throw.
|
||||
expect(updateArg.where.id).toBe("slot-row");
|
||||
expect(updateArg.data.value).toBe(1500);
|
||||
expect(updateArg.data.externalId).toContain("stats:");
|
||||
expect(summary.totals.daysFailed).toBe(0);
|
||||
expect(summary.totals.bucketsCollapsed).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@
|
||||
*/
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
|
||||
import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors";
|
||||
|
||||
import {
|
||||
CUMULATIVE_HK_TYPES,
|
||||
@@ -113,6 +114,12 @@ export interface DrainSummary {
|
||||
bucketsCollapsed: number;
|
||||
perSampleRowsDeleted: number;
|
||||
dailyRowsUpserted: number;
|
||||
/**
|
||||
* Day buckets whose write threw a non-recoverable error and were stepped
|
||||
* over by the per-day boundary. A single poisoned day no longer aborts the
|
||||
* whole global walk — every other day still collapses.
|
||||
*/
|
||||
daysFailed: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +193,7 @@ export async function drainPerSampleCumulative(
|
||||
bucketsCollapsed: 0,
|
||||
perSampleRowsDeleted: 0,
|
||||
dailyRowsUpserted: 0,
|
||||
daysFailed: 0,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -226,85 +234,127 @@ export async function drainPerSampleCumulative(
|
||||
dayRows,
|
||||
sourceRowIds,
|
||||
}): Promise<DayWriteOutcome> => {
|
||||
let removed = 0;
|
||||
await pc.$transaction(async (tx) => {
|
||||
// Fold late per-sample rows INTO an existing collapsed daily
|
||||
// total rather than overwriting it. `bucketRowsByDay` strips
|
||||
// already-collapsed `stats:` rows from the bucket, so when a day
|
||||
// was collapsed on an earlier run and fresh per-sample rows
|
||||
// arrive late (a trailing watch sync after the day's total was
|
||||
// already minted), this bucket holds ONLY those late samples.
|
||||
// A blind `update: { value: reducedValue }` would replace the
|
||||
// full day total with just the partial late sum — and the
|
||||
// original contributing samples were already hard-deleted, so
|
||||
// the lost count is unrecoverable.
|
||||
//
|
||||
// The probe runs inside the same transaction as the upsert +
|
||||
// delete so the read-modify-write of the total is atomic against
|
||||
// a concurrent drain. The late samples are genuine additional
|
||||
// readings (unlike the legacy-step pass, where the existing
|
||||
// total is HealthKit's own aggregate and the legacy rows would
|
||||
// double-count — so that sibling deliberately does NOT fold),
|
||||
// therefore the correct merge is `existing + late sum`. The
|
||||
// per-sample rows are hard-deleted either way so a re-run cannot
|
||||
// re-accumulate them.
|
||||
const existing = await tx.measurement.findUnique({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
// Adopt-in-place canonical-noon resolution + a single P2002 retry,
|
||||
// mirroring `dense-intraday-retention.ts`. Two unique indexes can
|
||||
// collide when minting the daily-stats row:
|
||||
// A) `(userId, type, source, externalId)` — a row already carrying
|
||||
// the target `stats:` externalId (a prior collapse of this day).
|
||||
// B) `(userId, type, measuredAt, source, sleepStage)` NULLS NOT
|
||||
// DISTINCT — ANY row sitting on the canonical local-noon instant,
|
||||
// which may carry a DIFFERENT externalId (a per-sample row that
|
||||
// happened at local noon and is in this very bucket, or a manual
|
||||
// daily row). A blind `create` against index B aborts the whole
|
||||
// transaction, and — before the per-day error boundary below — the
|
||||
// first colliding day aborted the ENTIRE global walk (3 s die-early,
|
||||
// 0 collapsed). Resolve the canonical row by index A first (it IS
|
||||
// the daily row by construction), else the index-B slot row, and
|
||||
// adopt it in place instead of colliding.
|
||||
const foldOnce = async (): Promise<number> => {
|
||||
let removed = 0;
|
||||
await pc.$transaction(async (tx) => {
|
||||
// Index-A row: already carries the target `stats:` externalId. When
|
||||
// present, this is the existing collapsed daily total.
|
||||
const eidRow = await tx.measurement.findFirst({
|
||||
where: { userId, type, source: "APPLE_HEALTH", externalId },
|
||||
select: { id: true, value: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
// Index-B row: occupies the canonical local-noon instant
|
||||
// (`sleepStage` is NULL for these cumulative types). At most one.
|
||||
const slotRow = await tx.measurement.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId,
|
||||
measuredAt: canonicalTimestamp,
|
||||
sleepStage: null,
|
||||
},
|
||||
},
|
||||
select: { value: true },
|
||||
});
|
||||
const mergedValue =
|
||||
existing !== null ? existing.value + reducedValue : reducedValue;
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
// Upsert the daily-aggregated row first, then drop the
|
||||
// per-sample rows. The unique index
|
||||
// (userId, type, source, externalId) makes the upsert
|
||||
// idempotent across re-runs.
|
||||
await tx.measurement.upsert({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId,
|
||||
type,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
type,
|
||||
value: reducedValue,
|
||||
// pick the canonical unit from an existing row; the
|
||||
// per-sample rows all carry the same unit on a given type.
|
||||
// dayRows always has ≥1 row (empty buckets are skipped).
|
||||
unit:
|
||||
dayRows[0]?.value !== undefined
|
||||
? await resolveCanonicalUnit(tx, userId, type)
|
||||
: "count",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: canonicalTimestamp,
|
||||
externalId,
|
||||
},
|
||||
update: {
|
||||
value: mergedValue,
|
||||
measuredAt: canonicalTimestamp,
|
||||
},
|
||||
});
|
||||
// Prefer the externalId-carrying row (index A) — adopting a sibling
|
||||
// and stamping the target externalId onto it would collide with it.
|
||||
const adoptTarget = eidRow ?? slotRow;
|
||||
|
||||
// Delete the per-sample rows that contributed to the sum.
|
||||
// Using `id IN (...)` is bounded by the per-bucket cap (the
|
||||
// largest cumulative bucket in real data is a ~1 440-row
|
||||
// stepCount day on a phone-only user).
|
||||
const del = await tx.measurement.deleteMany({
|
||||
where: { id: { in: sourceRowIds } },
|
||||
// Fold semantics. When the index-A `stats:` total already exists,
|
||||
// late per-sample rows are GENUINE additional readings, so the
|
||||
// correct merge is `existing + late sum` (a blind overwrite with the
|
||||
// partial late sum would shrink the day — the original samples are
|
||||
// already hard-deleted). When adopting a NON-stats slot row (a
|
||||
// per-sample row that fell on local noon, itself part of this
|
||||
// bucket), its value is already inside `reducedValue`, so the merged
|
||||
// value is just `reducedValue`. A fresh day mints `reducedValue`.
|
||||
const mergedValue =
|
||||
eidRow !== null ? eidRow.value + reducedValue : reducedValue;
|
||||
|
||||
let canonicalRowId: string;
|
||||
if (adoptTarget) {
|
||||
// Pin the adopted row to local-noon only when the canonical slot
|
||||
// is free or already this row — a DIFFERENT row occupying the slot
|
||||
// would otherwise collide on index B. In that rare case (a tz/DST
|
||||
// shift between mints) the row keeps its existing instant; the
|
||||
// `stats:` externalId is the identity, measuredAt is secondary.
|
||||
const slotIsFreeForTarget =
|
||||
slotRow === null || slotRow.id === adoptTarget.id;
|
||||
await tx.measurement.update({
|
||||
where: { id: adoptTarget.id },
|
||||
data: {
|
||||
value: mergedValue,
|
||||
externalId,
|
||||
deletedAt: null,
|
||||
...(slotIsFreeForTarget
|
||||
? { measuredAt: canonicalTimestamp }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
canonicalRowId = adoptTarget.id;
|
||||
} else {
|
||||
const created = await tx.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type,
|
||||
value: reducedValue,
|
||||
// pick the canonical unit from an existing row; the per-sample
|
||||
// rows all carry the same unit on a given type. dayRows always
|
||||
// has ≥1 row (empty buckets are skipped).
|
||||
unit:
|
||||
dayRows[0]?.value !== undefined
|
||||
? await resolveCanonicalUnit(tx, userId, type)
|
||||
: "count",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: canonicalTimestamp,
|
||||
externalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
canonicalRowId = created.id;
|
||||
}
|
||||
|
||||
// Hard-delete the per-sample rows that contributed to the sum.
|
||||
// EXCLUDE the adopted canonical row: a per-sample row that fell on
|
||||
// local-noon may be the row we just adopted as the daily total, so
|
||||
// deleting it would erase the fold. `id IN (...)` is bounded by the
|
||||
// per-bucket cap (largest real bucket is a ~1 440-row stepCount day).
|
||||
const del = await tx.measurement.deleteMany({
|
||||
where: { id: { in: sourceRowIds, not: canonicalRowId } },
|
||||
});
|
||||
removed = del.count;
|
||||
});
|
||||
removed = del.count;
|
||||
});
|
||||
return removed;
|
||||
};
|
||||
|
||||
let removed: number;
|
||||
try {
|
||||
removed = await foldOnce();
|
||||
} catch (err) {
|
||||
// A concurrent writer won the canonical slot mid-transaction. Retry
|
||||
// once: the deterministic lookup now resolves the winning row and
|
||||
// adopts it, so this pass cannot create a duplicate. Non-P2002 errors
|
||||
// propagate to the per-day `onBucketError` boundary below.
|
||||
if (!isUniqueConstraintViolation(err)) throw err;
|
||||
removed = await foldOnce();
|
||||
}
|
||||
return { kind: "written", sourceRowsRemoved: removed };
|
||||
},
|
||||
recordBucket: ({
|
||||
@@ -335,6 +385,18 @@ export async function drainPerSampleCumulative(
|
||||
: dayRows.length;
|
||||
summary.totals.dailyRowsUpserted += 1;
|
||||
},
|
||||
// Per-day failure boundary. A day whose write throws (e.g. a residual
|
||||
// unique-index collision the adopt-in-place path could not absorb) is
|
||||
// logged and STEPPED OVER so the global walk keeps draining every other
|
||||
// user / type / day — the historical behaviour aborted the whole run on
|
||||
// the first poisoned day (3 s die-early, 0 collapsed).
|
||||
onBucketError: ({ userId, type, dateKey, error }) => {
|
||||
summary.totals.daysFailed += 1;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(
|
||||
`[drain] user=${userId} type=${type} day=${dateKey} skipped — write failed: ${message}`,
|
||||
);
|
||||
},
|
||||
onUserStart: ({ userId, tz, dryRun }) => {
|
||||
log(`[drain] user=${userId} tz=${tz}${dryRun ? " (dry-run)" : ""}`);
|
||||
beforeBucketsCollapsed = summary.totals.bucketsCollapsed;
|
||||
@@ -360,7 +422,7 @@ export async function drainPerSampleCumulative(
|
||||
summary.totals.usersScanned = usersScanned;
|
||||
|
||||
log(
|
||||
`[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}${options.dryRun ? " (dry-run)" : ""}`,
|
||||
`[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFailed=${summary.totals.daysFailed}${options.dryRun ? " (dry-run)" : ""}`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user