Fix Postgres-only rank-tracking & site-audit workflow failures (#317)
This commit is contained in:
parent
19a4bc6585
commit
fa0651b96d
@ -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,
|
||||||
|
],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
29
src/server/workflows/pgStep.ts
Normal file
29
src/server/workflows/pgStep.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user