import type { WorkflowStep } from "cloudflare:workers"; import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { fetchRankCheckTaskResult, MAX_TASKS_PER_POST, } from "@/server/lib/dataforseo"; import type { createDataforseoClient, PostedRankCheckTask, RankCheckResult, RankCheckTaskInput, } from "@/server/lib/dataforseo"; import type { RankTrackingConfig } from "@/types/schemas/rank-tracking"; import { KEYWORDS_PER_BATCH } from "@/shared/rank-tracking"; import { pgStep } from "@/server/workflows/pgStep"; const SINGLE_ATTEMPT_STEP_CONFIG = { retries: { limit: 0, delay: "1 second" as const }, timeout: "2 minutes" as const, }; type KeywordEntry = { id: string; keyword: string }; type RankCheckResultWithDevice = RankCheckResult & { device: "desktop" | "mobile"; }; function mapResultsToSnapshotRows( runId: string, results: RankCheckResultWithDevice[], ) { return results.map((r) => ({ runId, trackingKeywordId: r.keywordId, keyword: r.keyword, device: r.device, position: r.position, url: r.url, serpFeatures: r.serpFeatures.length > 0 ? JSON.stringify(r.serpFeatures) : null, })); } interface CheckContext { client: ReturnType; keywords: KeywordEntry[]; devices: RankTrackingConfig["devices"]; serpDepth: number; domain: string; locationCode: number; languageCode: string; runId: string; } /** Expand keywords into one task input per keyword/device pair. */ function expandToTaskInputs( keywords: KeywordEntry[], devices: RankTrackingConfig["devices"], ): RankCheckTaskInput[] { const deviceList: Array<"desktop" | "mobile"> = devices === "both" ? ["desktop", "mobile"] : [devices]; return keywords.flatMap((kw) => deviceList.map((device) => ({ keyword: kw.keyword, keywordId: kw.id, device, })), ); } // --------------------------------------------------------------------------- // Step bodies. Each runs inside a single step.do: inputs are its parameters, // the return value is what the workflow engine persists and replays. They must // not touch any mutable state outside their arguments. // --------------------------------------------------------------------------- /** * Check keyword/device pairs against the live endpoint and persist snapshots. * Per-call failures are logged and skipped (the metered client already charged * or refused each call individually). Returns the snapshot count written. */ async function checkBatchLive( ctx: CheckContext, tasks: RankCheckTaskInput[], ): Promise { const settled = await Promise.allSettled( tasks.map((task) => ctx.client.serp .rankCheck({ keyword: task.keyword, keywordId: task.keywordId, locationCode: ctx.locationCode, languageCode: ctx.languageCode, device: task.device, targetDomain: ctx.domain, depth: ctx.serpDepth, }) .then((r) => ({ ...r, device: task.device })), ), ); const results: RankCheckResultWithDevice[] = []; for (const outcome of settled) { if (outcome.status === "fulfilled") { results.push(outcome.value); } else { console.error( `[rank-check] ${ctx.runId} live call failed:`, outcome.reason, ); } } if (results.length > 0) { await RankTrackingRepository.insertSnapshots( mapResultsToSnapshotRows(ctx.runId, results), ); } return results.length; } /** * Check keywords via Live API, parallel devices per keyword, real-time progress. * Snapshots are written incrementally after each batch so partial results * survive batch failures. ~6s per keyword batch. * Billing is handled per-call by the metered client. */ export async function runLiveCheck( step: WorkflowStep, ctx: CheckContext, ): Promise { for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) { const keywordBatch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH); const batchTasks = expandToTaskInputs(keywordBatch, ctx.devices); const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH); const keywordsChecked = i + keywordBatch.length; await pgStep( step, `live-batch-${batchIndex}`, SINGLE_ATTEMPT_STEP_CONFIG, async () => { const written = await checkBatchLive(ctx, batchTasks); // Progress for the UI; finalize recounts from the DB anyway. await RankTrackingRepository.updateRun(ctx.runId, { keywordsChecked, }); return written; }, ); } } // Poll cadence for queued tasks. Standard-priority tasks complete in ~5 // minutes on average, so the first check waits 4 minutes; cumulative waits are // 4 / 6 / 8 / 10 / 12 / 15 minutes, after which stragglers fall back to the // live endpoint. const QUEUED_POLL_INTERVALS = [ "4 minutes", "2 minutes", "2 minutes", "2 minutes", "2 minutes", "3 minutes", ] as const; /** Concurrent task_get requests within a collect step. */ const TASK_GET_CONCURRENCY = 25; /** Max task_get calls per collect round (per-invocation subrequest budget). */ const TASK_GETS_PER_COLLECT = 500; // Collect steps may issue hundreds of task_get calls, so they get more room // than SINGLE_ATTEMPT_STEP_CONFIG's 2-minute timeout. Unlike the metered // steps, retrying is safe and free: task_get isn't charged and snapshot // inserts are onConflictDoNothing. const COLLECT_STEP_CONFIG = { retries: { limit: 2, delay: "10 seconds" as const }, timeout: "5 minutes" as const, }; interface CollectRoundOutcome { /** Snapshots written this round. */ collected: number; /** Tasks still in DataForSEO's queue — poll again next round. */ stillPending: PostedRankCheckTask[]; /** Tasks DataForSEO failed — route to the live fallback. */ failed: PostedRankCheckTask[]; } /** * Fetch results for queued tasks (one free task_get each), persist completed * snapshots, and update run progress. Transient task_get failures stay * pending for the next round. */ async function collectQueuedRound( ctx: CheckContext, tasks: PostedRankCheckTask[], ): Promise { const completed: RankCheckResultWithDevice[] = []; const stillPending: PostedRankCheckTask[] = []; const failed: PostedRankCheckTask[] = []; for (let i = 0; i < tasks.length; i += TASK_GET_CONCURRENCY) { const chunk = tasks.slice(i, i + TASK_GET_CONCURRENCY); const settled = await Promise.allSettled( chunk.map((task) => fetchRankCheckTaskResult({ taskId: task.taskId, keywordId: task.keywordId, keyword: task.keyword, targetDomain: ctx.domain, }), ), ); settled.forEach((result, index) => { const task = chunk[index]; if (result.status === "rejected") { // Transient fetch failure — try again next round. console.warn( `[rank-check] ${ctx.runId} task_get failed:`, result.reason, ); stillPending.push(task); } else if (result.value.status === "pending") { stillPending.push(task); } else if (result.value.status === "failed") { console.warn( `[rank-check] ${ctx.runId} task ${task.taskId} failed: ${result.value.message}`, ); failed.push(task); } else { completed.push({ ...result.value.result, device: task.device }); } }); } if (completed.length > 0) { await RankTrackingRepository.insertSnapshots( mapResultsToSnapshotRows(ctx.runId, completed), ); // Progress for the UI; finalize recounts from the DB anyway. const snapshots = await RankTrackingRepository.getSnapshotsForRun( ctx.runId, ); await RankTrackingRepository.updateRun(ctx.runId, { keywordsChecked: new Set(snapshots.map((s) => s.trackingKeywordId)).size, }); } return { collected: completed.length, stillPending, failed }; } /** Per-run accounting for the queued path, in keyword/device task units. */ export interface QueuedCheckStats { /** Tasks accepted into DataForSEO's queue. */ queueTasks: number; /** Task results collected from the queue within the polling window. */ queueCollected: number; /** Tasks routed to the live fallback (rejected, failed, or timed out). */ fallbackTasks: number; /** Fallback tasks that produced a snapshot. */ fallbackChecked: number; } /** * Check keywords via DataForSEO's standard task queue (~30% of live cost). * Posts every keyword/device pair as a queued task, then polls task_get for * ~15 minutes, writing snapshots incrementally as tasks complete. Anything * still unfinished after the polling window — plus tasks DataForSEO rejected * or failed — gets one shot at the live endpoint so a run never hangs on a * stuck queue. Billing happens at task_post (and per live-fallback call). */ export async function runQueuedCheck( step: WorkflowStep, ctx: CheckContext, ): Promise { const taskInputs = expandToTaskInputs(ctx.keywords, ctx.devices); // Post all tasks to the queue, <=100 per request, one metered step each. // A failed chunk must not abort the run — earlier chunks were already // charged at DataForSEO, so their results have to be collected. The failed // chunk's pairs go to the live fallback instead. let pending: PostedRankCheckTask[] = []; const fallback: RankCheckTaskInput[] = []; for (let i = 0; i < taskInputs.length; i += MAX_TASKS_PER_POST) { const chunk = taskInputs.slice(i, i + MAX_TASKS_PER_POST); const postIndex = Math.floor(i / MAX_TASKS_PER_POST); let posted: PostedRankCheckTask[]; try { posted = await pgStep( step, `post-tasks-${postIndex}`, SINGLE_ATTEMPT_STEP_CONFIG, async () => ctx.client.serp.rankCheckTaskPost({ tasks: chunk, locationCode: ctx.locationCode, languageCode: ctx.languageCode, depth: ctx.serpDepth, targetDomain: ctx.domain, }), ); } catch (error) { console.warn( `[rank-check] ${ctx.runId} post-tasks-${postIndex} failed:`, error, ); fallback.push(...chunk); continue; } pending.push(...posted); if (posted.length < chunk.length) { const acceptedKeys = new Set( posted.map((t) => `${t.keywordId}:${t.device}`), ); fallback.push( ...chunk.filter((t) => !acceptedKeys.has(`${t.keywordId}:${t.device}`)), ); } } const stats: QueuedCheckStats = { queueTasks: pending.length, queueCollected: 0, fallbackTasks: 0, fallbackChecked: 0, }; // Poll until everything is collected or the ~15 minute window closes. A // collect failure (past its retries) leaves that round's tasks pending for // the next round — or the live fallback — instead of failing the run; the // posted tasks are already paid for. for ( let round = 0; round < QUEUED_POLL_INTERVALS.length && pending.length > 0; round++ ) { await step.sleep(`wait-${round}`, QUEUED_POLL_INTERVALS[round]); // Cap task_gets per round so one collect step stays well inside the // per-invocation subrequest limit at the 1000-keyword config ceiling. const batch = pending.slice(0, TASK_GETS_PER_COLLECT); const overflow = pending.slice(TASK_GETS_PER_COLLECT); let outcome: CollectRoundOutcome; try { outcome = await pgStep( step, `collect-${round}`, COLLECT_STEP_CONFIG, () => collectQueuedRound(ctx, batch), ); } catch (error) { console.warn(`[rank-check] ${ctx.runId} collect-${round} failed:`, error); continue; } stats.queueCollected += outcome.collected; pending = [...outcome.stillPending, ...overflow]; fallback.push(...outcome.failed); } // Live fallback: queued tasks that never finished, failed, or were rejected // at post time. A straggler is double-billed (customer was metered the // queued post cost and now the live call too — fractions of a cent). // Progress isn't updated here; finalize recounts keywordsChecked from the // DB. const stragglers: RankCheckTaskInput[] = [...fallback, ...pending]; stats.fallbackTasks = stragglers.length; if (stragglers.length === 0) return stats; console.log( `[rank-check] ${ctx.runId} live fallback for ${stragglers.length} task(s)`, ); for (let i = 0; i < stragglers.length; i += KEYWORDS_PER_BATCH) { const batch = stragglers.slice(i, i + KEYWORDS_PER_BATCH); const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH); stats.fallbackChecked += await pgStep( step, `fallback-batch-${batchIndex}`, SINGLE_ATTEMPT_STEP_CONFIG, () => checkBatchLive(ctx, batch), ); } return stats; }