import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep, } from "cloudflare:workers"; import { NonRetryableError } from "cloudflare:workflows"; import { withPgClient } from "@/db"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { failRunIfActive } from "@/server/features/rank-tracking/services/rankCheckRunGuards"; import { runLiveCheck, runQueuedCheck, type QueuedCheckStats, } from "@/server/workflows/rankCheckPaths"; import { pgStep } from "@/server/workflows/pgStep"; import { createDataforseoClient } from "@/server/lib/dataforseo"; import { captureServerEvent } from "@/server/lib/posthog"; import { AppError } from "@/server/lib/errors"; import { autumn } from "@/server/billing/autumn"; import { AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, } from "@/shared/billing"; import { estimateRankCheckCredits } from "@/shared/rank-tracking"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; const SINGLE_ATTEMPT_STEP_CONFIG = { retries: { limit: 0, delay: "1 second" as const }, timeout: "2 minutes" as const, }; interface RankCheckParams { runId: string; configId: string; billingCustomer: BillingCustomerContext; projectId: string; domain: string; locationCode: number; languageCode: string; devices: "both" | "desktop" | "mobile"; serpDepth: number; trigger: "manual" | "scheduled"; keywordIds?: string[]; } async function prepareRankCheckKeywords(input: { runId: string; configId: string; billingCustomer: BillingCustomerContext; devices: RankCheckParams["devices"]; serpDepth: number; trigger: RankCheckParams["trigger"]; keywordIds?: string[]; }) { // If stale-cleanup marked our run failed before we got here, bail out // rather than resurrecting a superseded run. const run = await RankTrackingRepository.getRunById(input.runId); if (!run || run.status === "failed" || run.status === "completed") { throw new NonRetryableError( `Run ${input.runId} is no longer active (status=${run?.status ?? "missing"})`, ); } await RankTrackingRepository.updateRun(input.runId, { status: "running", }); let trackingKeywords = await RankTrackingRepository.getKeywordsForConfig( input.configId, ); if (input.keywordIds && input.keywordIds.length > 0) { const idSet = new Set(input.keywordIds); trackingKeywords = trackingKeywords.filter((kw) => idSet.has(kw.id)); } if (trackingKeywords.length === 0) { throw new AppError("INTERNAL_ERROR", "No keywords to track"); } // Verify the user has enough credits for the full check before starting. // Scheduled checks go through the cheaper task queue, so estimate at queued // pricing — a live-price estimate would skip checks the user can afford. if (await isHostedServerAuthMode()) { const { costCredits } = estimateRankCheckCredits( trackingKeywords.length, input.devices, input.serpDepth, input.trigger === "scheduled" ? "queued" : "live", ); const [monthlyCheck, topupCheck] = await Promise.all([ autumn.check({ customerId: input.billingCustomer.organizationId, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, }), autumn.check({ customerId: input.billingCustomer.organizationId, featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, }), ]); const available = (monthlyCheck.balance?.remaining ?? 0) + (topupCheck.balance?.remaining ?? 0); if (available < costCredits) { throw new AppError( "INSUFFICIENT_CREDITS", "Insufficient credits for rank check", ); } } await RankTrackingRepository.updateRun(input.runId, { keywordsTotal: trackingKeywords.length, }); return { keywords: trackingKeywords.map((kw) => ({ id: kw.id, keyword: kw.keyword, })), }; } async function finalizeRankCheckRun(input: { runId: string; configId: string; projectId: string; billingCustomer: BillingCustomerContext; trigger: RankCheckParams["trigger"]; batchError: string | null; queueStats: QueuedCheckStats | null; }) { // If stale-cleanup already marked our run failed, don't overwrite that // decision with a completed status — a replacement run may already be // underway. const run = await RankTrackingRepository.getRunById(input.runId); if (!run || run.status === "failed" || run.status === "completed") { console.warn( `[rank-check] ${input.runId} no longer active (status=${run?.status ?? "missing"}), skipping finalization`, ); return; } const nowIso = new Date().toISOString(); // Snapshots were written incrementally by each batch step. // Count from DB to get the authoritative keyword count. const snapshots = await RankTrackingRepository.getSnapshotsForRun( input.runId, ); const keywordsChecked = new Set(snapshots.map((s) => s.trackingKeywordId)) .size; const keywordsTotal = run.keywordsTotal || keywordsChecked; const incompleteCount = keywordsTotal - keywordsChecked; let errorMessage: string | undefined; if (input.batchError) { errorMessage = `Completed ${keywordsChecked} of ${keywordsTotal} keyword(s). Error: ${input.batchError}`; } else if (incompleteCount > 0) { errorMessage = `${incompleteCount} keyword(s) could not be checked`; } // Flipping status away from 'pending'/'running' is what releases the // partial-index slot for the next run. await RankTrackingRepository.updateRun(input.runId, { status: "completed", keywordsChecked, completedAt: nowIso, ...(errorMessage ? { errorMessage } : {}), }); // Clear any previous skip reason on success. // Note: nextCheckAt is NOT set here — the cron handler advances it eagerly // before starting the workflow to prevent retry storms. await RankTrackingRepository.updateConfig(input.configId, input.projectId, { lastCheckedAt: nowIso, lastSkipReason: null, }); // One-line summary per run so fallback rates are visible in Workers Logs. // Keys match the PostHog event properties for log/event correlation. const queueSummary = input.queueStats ? ` queue_tasks=${input.queueStats.queueTasks} queue_collected=${input.queueStats.queueCollected} fallback_tasks=${input.queueStats.fallbackTasks} fallback_checked=${input.queueStats.fallbackChecked}` : ""; // Error text can echo vendor/user content — keep it one line and bounded. const errorSummary = errorMessage ? ` error="${errorMessage.replace(/\s+/g, " ").slice(0, 200)}"` : ""; console.log( `[rank-check] ${input.runId} completed org=${input.billingCustomer.organizationId} project=${input.projectId} trigger=${input.trigger} keywords=${keywordsChecked}/${keywordsTotal}${queueSummary}${errorSummary}`, ); await captureServerEvent({ distinctId: input.billingCustomer.userId, event: "rank_tracking:check_complete", organizationId: input.billingCustomer.organizationId, properties: { project_id: input.projectId, status: "completed", trigger: input.trigger, keywords_checked: keywordsChecked, ...(input.queueStats ? { queue_tasks: input.queueStats.queueTasks, queue_collected: input.queueStats.queueCollected, fallback_tasks: input.queueStats.fallbackTasks, fallback_checked: input.queueStats.fallbackChecked, } : {}), }, }); } async function markRankCheckRunFailed(input: { runId: string; configId: string; projectId: string; billingCustomer: BillingCustomerContext; error: unknown; }) { const errorMessage = input.error instanceof Error ? input.error.message : "Unknown error"; await failRunIfActive(input.runId, errorMessage); // Flag the config so the UI can show why the scheduled check was skipped const isInsufficientCredits = input.error instanceof AppError && input.error.code === "INSUFFICIENT_CREDITS"; if (isInsufficientCredits) { await RankTrackingRepository.updateConfig(input.configId, input.projectId, { lastSkipReason: "insufficient_credits", }); } await captureServerEvent({ distinctId: input.billingCustomer.userId, event: "rank_tracking:check_complete", organizationId: input.billingCustomer.organizationId, properties: { project_id: input.projectId, status: "failed", error: errorMessage, }, }); } export class RankCheckWorkflow extends WorkflowEntrypoint< Env, RankCheckParams > { async run(event: WorkflowEvent, step: WorkflowStep) { // Scope a per-request Postgres client for this workflow invocation (no-op in // D1 mode). The socket is reclaimed when the invocation ends, so there is // nothing to tear down here. return withPgClient(() => this.runScoped(event, step)); } private async runScoped( event: WorkflowEvent, step: WorkflowStep, ) { const { runId, configId, billingCustomer, projectId, domain, locationCode, languageCode, devices, serpDepth, trigger, keywordIds, } = event.payload; const client = createDataforseoClient(billingCustomer); // Guard: skip if config was archived after the workflow was triggered const configCheck = await pgStep( step, "check-active", { retries: { limit: 0, delay: "1 second" } }, async () => { const cfg = await RankTrackingRepository.getConfigById({ configId, projectId, }); return { isActive: cfg?.isActive ?? false }; }, ); if (!configCheck.isActive) { await failRunIfActive(runId, "Config has been archived"); return; } try { console.log( `[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`, ); const prepareResult = await pgStep( step, "prepare", { retries: { limit: 0, delay: "1 second" } }, async () => prepareRankCheckKeywords({ runId, configId, billingCustomer, devices, serpDepth, trigger, keywordIds, }), ); const keywords = prepareResult.keywords; console.log(`[rank-check] ${runId} loaded ${keywords.length} keywords`); let batchError: string | null = null; let queueStats: QueuedCheckStats | null = null; try { const checkContext = { client, keywords, devices, serpDepth, domain, locationCode, languageCode, runId, }; // Scheduled checks use DataForSEO's task queue (~30% of live cost); // manual checks stay on the live endpoint for instant results. if (trigger === "scheduled") { queueStats = await runQueuedCheck(step, checkContext); } else { await runLiveCheck(step, checkContext); } } catch (error) { // Batch failure — snapshots for completed batches are already // persisted incrementally. Continue to finalization. batchError = error instanceof Error ? error.message : String(error); console.warn(`[rank-check] ${runId} partial failure: ${batchError}`); } await pgStep(step, "finalize", SINGLE_ATTEMPT_STEP_CONFIG, async () => finalizeRankCheckRun({ runId, configId, projectId, billingCustomer, trigger, batchError, queueStats, }), ); } catch (error) { console.error(`Rank check ${runId} failed:`, error); await pgStep(step, "mark-failed", SINGLE_ATTEMPT_STEP_CONFIG, async () => markRankCheckRunFailed({ runId, configId, projectId, billingCustomer, error, }), ); throw error; } } }