Fix Postgres-only rank-tracking & site-audit workflow failures (#317)

This commit is contained in:
Ben Senescu 2026-06-30 09:51:29 -04:00 committed by Ben Senescu
parent 19a4bc6585
commit fa0651b96d
7 changed files with 85 additions and 21 deletions

View File

@ -202,8 +202,23 @@ async function insertSnapshots(
Omit<InferInsertModel<typeof rankSnapshots>, "id" | "checkedAt"> Omit<InferInsertModel<typeof rankSnapshots>, "id" | "checkedAt">
>, >,
) { ) {
// Target the (run, keyword, device) unique index explicitly. An UNtargeted
// ON CONFLICT DO NOTHING also swallows a primary-key collision, which would
// silently drop every row if the `id` serial sequence ever drifts behind
// max(id) (e.g. after a data import that copied explicit ids). Scoping the
// clause to the intended dedupe index keeps re-runs idempotent while letting
// a pk collision surface as a loud duplicate-key error instead of data loss.
await executeInBatches(snapshots, (tx, snapshot) => await executeInBatches(snapshots, (tx, snapshot) =>
tx.insert(rankSnapshots).values(snapshot).onConflictDoNothing(), tx
.insert(rankSnapshots)
.values(snapshot)
.onConflictDoNothing({
target: [
rankSnapshots.runId,
rankSnapshots.trackingKeywordId,
rankSnapshots.device,
],
}),
); );
} }

View File

@ -13,6 +13,7 @@ import {
runQueuedCheck, runQueuedCheck,
type QueuedCheckStats, type QueuedCheckStats,
} from "@/server/workflows/rankCheckPaths"; } from "@/server/workflows/rankCheckPaths";
import { pgStep } from "@/server/workflows/pgStep";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import { createDataforseoClient } from "@/server/lib/dataforseo";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
@ -277,7 +278,8 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
const client = createDataforseoClient(billingCustomer); const client = createDataforseoClient(billingCustomer);
// Guard: skip if config was archived after the workflow was triggered // Guard: skip if config was archived after the workflow was triggered
const configCheck = await step.do( const configCheck = await pgStep(
step,
"check-active", "check-active",
{ retries: { limit: 0, delay: "1 second" } }, { retries: { limit: 0, delay: "1 second" } },
async () => { async () => {
@ -298,7 +300,8 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
`[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`, `[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`,
); );
const prepareResult = await step.do( const prepareResult = await pgStep(
step,
"prepare", "prepare",
{ retries: { limit: 0, delay: "1 second" } }, { retries: { limit: 0, delay: "1 second" } },
async () => async () =>
@ -345,7 +348,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
console.warn(`[rank-check] ${runId} partial failure: ${batchError}`); console.warn(`[rank-check] ${runId} partial failure: ${batchError}`);
} }
await step.do("finalize", SINGLE_ATTEMPT_STEP_CONFIG, async () => await pgStep(step, "finalize", SINGLE_ATTEMPT_STEP_CONFIG, async () =>
finalizeRankCheckRun({ finalizeRankCheckRun({
runId, runId,
configId, configId,
@ -358,7 +361,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
); );
} catch (error) { } catch (error) {
console.error(`Rank check ${runId} failed:`, error); console.error(`Rank check ${runId} failed:`, error);
await step.do("mark-failed", SINGLE_ATTEMPT_STEP_CONFIG, async () => await pgStep(step, "mark-failed", SINGLE_ATTEMPT_STEP_CONFIG, async () =>
markRankCheckRunFailed({ markRankCheckRunFailed({
runId, runId,
configId, configId,

View File

@ -15,6 +15,7 @@ import { AuditRepository } from "@/server/features/audit/repositories/AuditRepos
import type { AuditConfig } from "@/server/lib/audit/types"; import type { AuditConfig } from "@/server/lib/audit/types";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases"; import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
import { pgStep } from "@/server/workflows/pgStep";
interface AuditParams { interface AuditParams {
auditId: string; auditId: string;
@ -63,7 +64,7 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
}); });
} catch (error) { } catch (error) {
console.error(`Audit ${auditId} failed:`, error); console.error(`Audit ${auditId} failed:`, error);
await step.do("mark-failed", async () => { await pgStep(step, "mark-failed", undefined, async () => {
await AuditRepository.failAudit(auditId, event.instanceId); await AuditRepository.failAudit(auditId, event.instanceId);
const latestAudit = await AuditRepository.getAuditForWorkflow( const latestAudit = await AuditRepository.getAuditForWorkflow(

View File

@ -0,0 +1,29 @@
import type { WorkflowStep, WorkflowStepConfig } from "cloudflare:workers";
import { withPgClient } from "@/db";
/**
* `step.do` with a request-scoped Postgres client active inside the step body.
*
* Cloudflare Workflows invoke each step callback in its own execution context
* steps are independently persisted and can resume in a fresh invocation, so the
* `AsyncLocalStorage` scope opened by `withPgClient` around `run()` does NOT
* propagate into a step. Each DB-touching step must therefore open its own
* client. In D1 mode `withPgClient` is a no-op, so this is just a plain
* `step.do`. The client is lazy (postgres-js only connects on first query), so
* wrapping a step that happens not to touch the DB costs nothing.
*
* `T` mirrors `step.do`'s own `Rpc.Serializable<T>` bound so step results stay
* serializable (the workflow engine persists and replays them). Pass `undefined`
* for `config` to use the engine's default step config (matches the 2-arg
* `step.do(name, fn)` form).
*/
export function pgStep<T extends Rpc.Serializable<T>>(
step: WorkflowStep,
name: string,
config: WorkflowStepConfig | undefined,
fn: () => Promise<T>,
): Promise<T> {
return config
? step.do(name, config, () => withPgClient(fn))
: step.do(name, () => withPgClient(fn));
}

View File

@ -12,6 +12,7 @@ import type {
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking"; import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
import { KEYWORDS_PER_BATCH } from "@/shared/rank-tracking"; import { KEYWORDS_PER_BATCH } from "@/shared/rank-tracking";
import { pgStep } from "@/server/workflows/pgStep";
const SINGLE_ATTEMPT_STEP_CONFIG = { const SINGLE_ATTEMPT_STEP_CONFIG = {
retries: { limit: 0, delay: "1 second" as const }, retries: { limit: 0, delay: "1 second" as const },
@ -131,7 +132,8 @@ export async function runLiveCheck(
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH); const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
const keywordsChecked = i + keywordBatch.length; const keywordsChecked = i + keywordBatch.length;
await step.do( await pgStep(
step,
`live-batch-${batchIndex}`, `live-batch-${batchIndex}`,
SINGLE_ATTEMPT_STEP_CONFIG, SINGLE_ATTEMPT_STEP_CONFIG,
async () => { async () => {
@ -283,7 +285,8 @@ export async function runQueuedCheck(
const postIndex = Math.floor(i / MAX_TASKS_PER_POST); const postIndex = Math.floor(i / MAX_TASKS_PER_POST);
let posted: PostedRankCheckTask[]; let posted: PostedRankCheckTask[];
try { try {
posted = await step.do( posted = await pgStep(
step,
`post-tasks-${postIndex}`, `post-tasks-${postIndex}`,
SINGLE_ATTEMPT_STEP_CONFIG, SINGLE_ATTEMPT_STEP_CONFIG,
async () => async () =>
@ -339,8 +342,11 @@ export async function runQueuedCheck(
let outcome: CollectRoundOutcome; let outcome: CollectRoundOutcome;
try { try {
outcome = await step.do(`collect-${round}`, COLLECT_STEP_CONFIG, () => outcome = await pgStep(
collectQueuedRound(ctx, batch), step,
`collect-${round}`,
COLLECT_STEP_CONFIG,
() => collectQueuedRound(ctx, batch),
); );
} catch (error) { } catch (error) {
console.warn(`[rank-check] ${ctx.runId} collect-${round} failed:`, error); console.warn(`[rank-check] ${ctx.runId} collect-${round} failed:`, error);
@ -369,7 +375,8 @@ export async function runQueuedCheck(
const batch = stragglers.slice(i, i + KEYWORDS_PER_BATCH); const batch = stragglers.slice(i, i + KEYWORDS_PER_BATCH);
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH); const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
stats.fallbackChecked += await step.do( stats.fallbackChecked += await pgStep(
step,
`fallback-batch-${batchIndex}`, `fallback-batch-${batchIndex}`,
SINGLE_ATTEMPT_STEP_CONFIG, SINGLE_ATTEMPT_STEP_CONFIG,
() => checkBatchLive(ctx, batch), () => checkBatchLive(ctx, batch),

View File

@ -5,6 +5,7 @@ import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers"; import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers";
import { pgStep } from "@/server/workflows/pgStep";
const CRAWL_CONCURRENCY = 25; const CRAWL_CONCURRENCY = 25;
@ -236,10 +237,15 @@ async function persistCrawlProgress(params: {
); );
}); });
await step.do(`progress-batch-${crawlBatchIndex}`, async () => { await pgStep(
step,
`progress-batch-${crawlBatchIndex}`,
undefined,
async () => {
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
pagesCrawled, pagesCrawled,
pagesTotal: Math.min(visitedCount + queueLength, maxPages), pagesTotal: Math.min(visitedCount + queueLength, maxPages),
}); });
}); },
);
} }

View File

@ -15,6 +15,7 @@ import type {
} from "@/server/lib/audit/types"; } from "@/server/lib/audit/types";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl"; import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl";
import { pgStep } from "@/server/workflows/pgStep";
const LIGHTHOUSE_URL_BATCH_SIZE = 10; const LIGHTHOUSE_URL_BATCH_SIZE = 10;
@ -103,7 +104,7 @@ async function runDiscoveryPhase(
origin: string, origin: string,
maxPages: number, maxPages: number,
) { ) {
return step.do("discover-urls", async () => { return pgStep(step, "discover-urls", undefined, async () => {
const result = await discoverUrls(origin, maxPages); const result = await discoverUrls(origin, maxPages);
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
pagesTotal: Math.min(result.urls.length + 1, maxPages), pagesTotal: Math.min(result.urls.length + 1, maxPages),
@ -168,8 +169,10 @@ async function runLighthousePhase(
const counts = countLighthouseBatchResults(lighthouseBatchResults); const counts = countLighthouseBatchResults(lighthouseBatchResults);
failedChecks += counts.failed; failedChecks += counts.failed;
completedChecks += counts.completed; completedChecks += counts.completed;
await step.do( await pgStep(
step,
`lighthouse-progress-batch-${lighthouseBatchIndex}`, `lighthouse-progress-batch-${lighthouseBatchIndex}`,
undefined,
async () => { async () => {
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
lighthouseCompleted: completedChecks, lighthouseCompleted: completedChecks,
@ -192,7 +195,7 @@ async function selectLighthousePages(params: {
}) { }) {
const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } = const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } =
params; params;
return step.do("select-lighthouse-sample", async () => { return pgStep(step, "select-lighthouse-sample", undefined, async () => {
const sample = selectLighthouseSample(allPages, startUrl, strategy); const sample = selectLighthouseSample(allPages, startUrl, strategy);
const selectedUrls = new Set(sample); const selectedUrls = new Set(sample);
@ -274,7 +277,7 @@ async function finalizeAudit(args: {
lighthouseResults, lighthouseResults,
} = args; } = args;
await step.do("finalize", async () => { await pgStep(step, "finalize", undefined, async () => {
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
currentPhase: "finalizing", currentPhase: "finalizing",
}); });