metatron-open-seo/src/server/workflows/RankCheckWorkflow.ts

377 lines
12 KiB
TypeScript

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<RankCheckParams>, 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<RankCheckParams>,
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;
}
}
}