Merge branch 'fix/v1437-r2-jobs' into release/v1.43.7

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:17:01 +02:00
commit e5b7eebe64
62 files changed
+1745 -774

No files matched your search

@@ -31,6 +31,11 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/logging/signal", () => ({
emitSignal: vi.fn(),
logCaught: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -52,6 +57,7 @@ vi.mock("@/lib/crypto", () => ({
import { formatAdminAlertPayload, parkIntegrationAtReauth } from "../status";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { emitSignal } from "@/lib/logging/signal";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
beforeEach(() => {
@@ -246,9 +252,8 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", ()
}),
}),
);
expect(auditLog).not.toHaveBeenCalledWith(
"integrations.sync.failed",
expect.anything(),
expect(emitSignal).not.toHaveBeenCalledWith(
expect.objectContaining({ action: "integrations.sync.failed" }),
);
});
@@ -32,6 +32,16 @@ describe("walkPages", () => {
expect(walk.items).toEqual([0, 1, 2, 3]);
});
it("ends on an empty cursor instead of following it back to the first page", async () => {
const fetchPage = vi.fn(async (cursor: string | undefined) => ({
items: [cursor ?? "first"],
next: "",
}));
const walk = await walkPages<string, string>("test.empty", 10, fetchPage);
expect(walk).toEqual({ items: ["first"], pages: 1, truncated: false });
expect(fetchPage).toHaveBeenCalledTimes(1);
});
it("stops on a cursor it already followed, the first one included", async () => {
const fetchPage = vi.fn(async () => ({ items: [1], next: 0 }));
const walk = await walkPages<number, number>(
+34 -21
View File
@@ -36,6 +36,11 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/logging/signal", () => ({
emitSignal: vi.fn(),
logCaught: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -66,6 +71,7 @@ import {
} from "../status";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { emitSignal } from "@/lib/logging/signal";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
beforeEach(() => {
@@ -148,17 +154,24 @@ describe("recordSyncFailure — under threshold", () => {
});
expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect(emitSignal).toHaveBeenCalledWith(
expect.objectContaining({
userId: "u1",
details: expect.objectContaining({
action: "integrations.sync.failed",
meta: expect.objectContaining({
integration: "withings",
attemptNumber: 1,
attempt_number: 1,
state: "error_transient",
}),
}),
);
// A provider failure is not something a person did to the account: no
// audit row, and the provider's message stays sealed on the status row.
expect(auditLog).not.toHaveBeenCalledWith(
"integrations.sync.failed",
expect.anything(),
);
const [signal] = vi.mocked(emitSignal).mock.calls[0]!;
expect(JSON.stringify(signal)).not.toContain("upstream");
expect(dispatchNotification).not.toHaveBeenCalled();
});
});
@@ -456,12 +469,12 @@ describe("recordSyncFailure — reauth_required", () => {
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
expect(upsertArgs.update).toMatchObject({ state: "error_reauth" });
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect(emitSignal).toHaveBeenCalledWith(
expect.objectContaining({
details: expect.objectContaining({
action: "integrations.sync.failed",
meta: expect.objectContaining({
kind: "reauth_required",
errorCode: "100",
error_code: "100",
}),
}),
);
@@ -489,12 +502,12 @@ describe("recordSyncFailure — persistent (v1.4.42 W6)", () => {
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
expect(upsertArgs.update).toMatchObject({ state: "error_transient" });
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect(emitSignal).toHaveBeenCalledWith(
expect.objectContaining({
details: expect.objectContaining({
action: "integrations.sync.failed",
meta: expect.objectContaining({
kind: "persistent",
errorCode: "293",
error_code: "293",
}),
}),
);
@@ -1089,12 +1102,12 @@ describe("transient-streak escalation", () => {
persistent: 1,
});
expect(upsertUpdate().persistentFailureStartedAt).toBeInstanceOf(Date);
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect(emitSignal).toHaveBeenCalledWith(
expect.objectContaining({
details: expect.objectContaining({
action: "integrations.sync.failed",
meta: expect.objectContaining({
kind: "persistent",
escalatedFrom: "transient",
escalated_from: "transient",
}),
}),
);
@@ -1116,12 +1129,12 @@ describe("transient-streak escalation", () => {
persistent: 0,
});
expect(upsertUpdate().persistentFailureStartedAt).toBeNull();
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect(emitSignal).toHaveBeenCalledWith(
expect.objectContaining({
details: expect.objectContaining({
action: "integrations.sync.failed",
meta: expect.objectContaining({
kind: "transient",
escalatedFrom: null,
escalated_from: null,
}),
}),
);
+16 -3
View File
@@ -15,7 +15,14 @@
* The backfill contract (`backfillVerdict`), the same for every provider:
* - a failed collection is transient: the job throws and pg-boss retries;
* - a truncated collection is not complete: the marker is not stamped, and
* boot discovery offers the account again, without a retry storm;
* boot discovery offers the account again, without a retry storm. Where
* a real history can outrun the ceiling (Strava's forty pages, about
* 4 000 activities), the backfill records how far it got and the next
* run goes on from there (`strava-backfill.ts`), or each run would read
* the same newest pages again. The other ceilings sit far above any
* history (a thousand pages for WHOOP and Withings), so a truncation
* there is a cursor that does not advance, which no resume can cure;
* the repeat check ends it on the first page it would read twice;
* - a connection parked for re-authorisation is permanent until the person
* reconnects: the runner returns without stamping and without a retry.
*
@@ -32,7 +39,13 @@ import { AsyncLocalStorage } from "node:async_hooks";
import { annotate } from "@/lib/logging/context";
/** One page as the client read it. A null `next` ends the walk. */
/**
* One page as the client read it. A null, absent or empty `next` ends the
* walk: WHOOP and Oura answer the last page with `next_token: ""` as well as
* with null, and an empty token followed as a cursor is no cursor at all, so
* the client asked for the first page again, read it twice and the walk
* ended as a repeat, leaving the backfill incomplete for good.
*/
export interface WalkPage<C, T> {
items: readonly T[];
next: C | null | undefined;
@@ -66,7 +79,7 @@ export async function walkPages<C, T>(
const result = await fetchPage(cursor, page);
items.push(...result.items);
const next = result.next;
if (next === null || next === undefined) {
if (next === null || next === undefined || next === "") {
return { items, pages: page + 1, truncated: false };
}
if (seen.has(next)) return truncated(label, items, page + 1, "repeat");
+28 -30
View File
@@ -35,6 +35,7 @@ import { prisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
import { annotate, getEvent } from "@/lib/logging/context";
import { emitSignal } from "@/lib/logging/signal";
import {
dispatchNotification,
type DispatchOutcome,
@@ -558,7 +559,7 @@ export interface RecordSyncFailureInput {
* Record a sync failure. Always:
* - increments the per-kind bucket in `consecutiveFailuresByKind`
* - persists the encrypted error message
* - writes one `AuditLog` row with `integrations.sync.failed`
* - emits one `integrations.sync.failed` signal (not an audit row)
*
* If `kind === "reauth_required"` the row is parked at `error_reauth`
* so the next scheduled sync skips. If `kind === "persistent"` and
@@ -746,39 +747,36 @@ export async function recordSyncFailure(
errorCode: errorCode ?? null,
});
// Audit log entry — fire-and-await so an integration test can assert
// it. The auth/audit helper is its own DB write so it's safe to call
// serially without bloating latency in the success path (which never
// calls this).
// One signal per failed sync, not an audit row. The audit log is the
// trail of what a person or an admin did to an account; a provider that
// answered 503 is neither, and one row per failed sync drowned that trail
// (and kept the provider's message there in plain text, while the status
// row seals it). The signal carries the same counts to the log store, and
// the status row keeps the current streak and the sealed message.
//
// v1.4.47 — `attemptNumber` is now sourced from the bucket sum
// (the legacy `consecutiveFailures` column was dropped). The sum
// matches the legacy column's value for any row written after
// v1.4.43: a transient burst followed by a single persistent failure
// shows `attemptNumber = transient + persistent`, which is the same
// running total the legacy integer carried.
// `attempt_number` is the sum of the buckets, the running total the
// dropped `consecutiveFailures` column carried; the alert below uses the
// largest single bucket (docs/ops/attempt-number-semantics.md).
const bucketTotal =
buckets.transient + buckets.reauth_required + buckets.persistent;
await auditLog("integrations.sync.failed", {
userId,
details: {
emitSignal({
action: "integrations.sync.failed",
level: "warn",
meta: {
integration,
kind: effectiveKind,
// Non-null only when the clock overrode the classifier, so an operator
// reading the trail can tell "the upstream said this was permanent" from
// "we concluded it was after a day of retrying".
escalatedFrom: escalatedFromTransient ? kind : null,
failingLeg: leg,
// Every leg the row currently holds an error for, not just this one —
// the audit trail is where an operator reconstructs an overlapping
// outage, and one leg per row was never enough to do it.
failingLegs,
failingSinceAt: failingSinceAt.toISOString(),
streakAgeMs,
errorCode: errorCode ?? null,
message,
attemptNumber: bucketTotal,
bucketCount: buckets[effectiveKind],
// Non-null only when the clock overrode the classifier: "the upstream
// said this was permanent" against "a day of retrying concluded it".
escalated_from: escalatedFromTransient ? kind : null,
failing_leg: leg,
// Every leg the row holds an error for, to reconstruct an overlapping
// outage.
failing_legs: failingLegs,
failing_since_at: failingSinceAt.toISOString(),
streak_age_ms: streakAgeMs,
error_code: errorCode ?? null,
attempt_number: bucketTotal,
bucket_count: buckets[effectiveKind],
state: newState,
},
});
@@ -941,7 +939,7 @@ export async function resumeIntegrationFromPark(
* short-circuit. Unlike `recordSyncFailure`, this helper:
*
* 1. does NOT increment the per-kind failure buckets.
* 2. does NOT write an `integrations.sync.failed` audit row through
* 2. does NOT emit the `integrations.sync.failed` signal of
* `recordSyncFailure`. A standalone `integrations.reauth_required`
* row is written instead so the operations trail still shows the
* park event.