RankCheckWorkflow orchestrates rank-check runs across guard checks, keyword preparation, SERP fetching, and finalization, with guards against archived configs and stale-cleanup races via status idempotence and eager nextCheckAt advancement. Rank tracking enforces per-project config limits, preserves history through reactivation rather than duplication, scopes keyword metrics to location for local configs, and reports staleness without mutating on read paths.
RankCheckWorkflow is a Cloudflare Workflow (WorkflowEntrypoint) that orchestrates the full rank-check lifecycle: guard checks, keyword preparation, SERP fetching, and run finalization.[1] RankCheckWorkflow.run scopes a per-request Postgres client for the workflow invocation by calling withPgClient, which is a no-op in D1 mode.[1] All workflow steps in RankCheckWorkflow that must not be retried use SINGLE_ATTEMPT_STEP_CONFIG (retries: { limit: 0 }, timeout '2 minutes'), defined as a module-level constant.[1] Runs carry a trigger field typed as 'manual' | 'scheduled' in the RankCheckParams interface.[1]
At the very start of runScoped, RankCheckWorkflow calls RankTrackingRepository.getConfigById and immediately calls failRunIfActive then returns if isActive is false, guarding against archived configs.[1] prepareRankCheckKeywords throws a NonRetryableError (preventing Cloudflare Workflow retries) if the run is already 'failed' or 'completed', guarding against stale-cleanup resurrection of a superseded run.[1] prepareRankCheckKeywords filters the full config keyword list down to only the requested keywordIds when that parameter is provided, enabling subset and manual re-check runs.[1]
finalizeRankCheckRun counts completed snapshots directly from the DB via RankTrackingRepository.getSnapshotsForRun, rather than trusting the in-memory count, making it the authoritative keyword count for run finalization.[1] finalizeRankCheckRun does NOT update nextCheckAt on the config — the cron handler advances it eagerly before starting the workflow to prevent retry storms.[1] finalizeRankCheckRun skips finalization silently (with a warning log) if the run is already 'failed' or 'completed' when it runs, preventing it from overwriting a status set by stale-cleanup while a replacement run may already be underway.[1] Completion logging in finalizeRankCheckRun includes queue stats (queue_tasks, queue_collected, fallback_tasks, fallback_checked) when available, and caps error text at 200 characters with whitespace collapsed to prevent multi-line log entries from embedding vendor or user content.[1]
markRankCheckRunFailed sets lastSkipReason: 'insufficient_credits' on the config when the failure is an INSUFFICIENT_CREDITS error, enabling the UI to show why a scheduled check was skipped.[1] Both run completion and run failure emit a rank_tracking:check_complete PostHog event via captureServerEvent, distinguished by a status property ('completed' or 'failed').[1]
RankTrackingService.ts enforces a cap of MAX_CONFIGS_PER_PROJECT active rank-tracking configs per project, applied to both new configs and reactivations of archived ones, throwing a VALIDATION_ERROR AppError when exceeded.[2] Archiving a rank-tracking domain (setting isActive to false) does not delete the config row; re-adding the same domain reactivates the existing row — preserving keyword and ranking history — rather than creating a duplicate, as long as the project is below MAX_CONFIGS_PER_PROJECT.[2] When reactivating an archived config, RankTrackingService.createConfig clears lastSkipReason to prevent surfacing outdated skip warnings.[2] When scheduleInterval is "manual", nextCheckAt is set to null (no scheduled run); any other interval computes a future timestamp via computeNextCheckAt.[2]
getLatestRun reads but does not mutate stale workflow state — it calls reconcileActiveRankCheckRun only to report staleness, because mutating on this read path caused a race where the original workflow kept running while a replacement was started.[2] RankTrackingService.getTracker aggregates a config and its latest results into a single object by calling getValidatedConfig and getLatestResults together.[2]
refreshKeywordMetrics scopes keyword volume and CPC data to the tracked city (locationName) for local configs, because national numbers can overstate local demand by orders of magnitude.[2] refreshKeywordMetrics uses resolveKeywordDataLanguage to translate SERP language codes to keyword-data API language codes, because keyword-data APIs only serve a country's own languages even when the SERP tracker pairs any language with any country.[2]
Local rank tracking location search flows through searchSerpLocations server function, reading per-country lists from KV with a 30-day TTL and returning top 10 substring-matched results
// combobox (350 ms debounce) → server fn → KV cache → substring filter
searchSerpLocations(iso, query) // returns top 10 matching canonical location names
Selecting Local mode fires prewarmSerpLocations as a query with staleTime: Infinity to preload country location data before the first keystroke
useQuery({
queryFn: () => prewarmSerpLocations(country),
staleTime: Infinity,
})
Sources