* feat(rank-tracking): raise scheduler budget to 2000 units/tick The 200-unit budget used ~2-6% of DataForSEO's 2,000 req/min account cap and would take days to drain the post-#462 backlog. Scheduled checks run through the task queue (1 task_post per 100 units + free task_get polls), so a full 2,000-unit tick peaks around 1,200 req/min — still leaving headroom for the other DataForSEO products on the account. Also raise the due-config fetch limit to 500 so skip-heavy stretches (free orgs, keywordless configs) drain more than 200 rows per tick. * fix(rank-tracking): retune budget to 1000 with accurate sizing and a tick deadline Review corrections to the 10x bump: task_get polling is one call per unit per round and rounds wake synchronized per tick, with up to three ~15-min poll windows overlapping the */5 cron — 2000 units/tick could saturate DataForSEO's 2000 req/min cap, silently aging throttled polls into the ~3x-cost live fallback billed to customers. 1000/tick keeps real headroom and is still ~45x steady-state demand. Add a 3-minute wall-clock deadline to the per-config loop (stoppedByDeadline in the tick summary): a skip-heavy 500-candidate tick pays serial Autumn round-trips per distinct org and could otherwise run into the 15-minute cron kill. Name the fetch limit (DUE_CONFIGS_PER_TICK) and correct its comment. Test fixtures now derive from MAX_KEYWORDS_PER_CONFIG instead of asserting an unreachable 1500-keyword config. * fix(cron): run the audit watchdog before the rank loop reconcileStaleAudits ran after runScheduledRankChecks in the same invocation, so a slow rank tick would delay the watchdog and a wall-clock kill would skip it entirely. * fix(cron): preserve watchdog failure signal; codex review polish Rethrow a caught reconcileStaleAudits error after the rank loop so the invocation still reports failed (matching pre-reorder semantics), use an inclusive deadline comparison, and note overlapping-tick poll residue in the sizing comment.
496 lines
14 KiB
TypeScript
496 lines
14 KiB
TypeScript
import {
|
|
and,
|
|
asc,
|
|
count,
|
|
desc,
|
|
eq,
|
|
inArray,
|
|
isNull,
|
|
lte,
|
|
max,
|
|
ne,
|
|
} from "drizzle-orm";
|
|
import type { InferInsertModel } from "drizzle-orm";
|
|
import { db } from "@/db";
|
|
import {
|
|
rankTrackingConfigs,
|
|
rankCheckRuns,
|
|
rankSnapshots,
|
|
rankTrackingKeywords,
|
|
projects,
|
|
} from "@/db/schema";
|
|
import { DB_BATCH_SIZE, executeInBatches } from "@/db/runBatch";
|
|
import type { RankTrackingSkipReason } from "@/shared/rank-tracking";
|
|
import {
|
|
getLatestSnapshotsForKeywords,
|
|
getSnapshotsBeforeDate,
|
|
getEarliestSnapshotsForKeywords,
|
|
getKeywordHistory,
|
|
getConfigTrend,
|
|
getPositionMatrix,
|
|
} from "./snapshotQueries";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getConfigsForProject(projectId: string) {
|
|
return db
|
|
.select()
|
|
.from(rankTrackingConfigs)
|
|
.where(
|
|
and(
|
|
eq(rankTrackingConfigs.projectId, projectId),
|
|
eq(rankTrackingConfigs.isActive, true),
|
|
),
|
|
)
|
|
.orderBy(rankTrackingConfigs.createdAt);
|
|
}
|
|
|
|
async function getConfigById({
|
|
configId,
|
|
projectId,
|
|
}: {
|
|
configId: string;
|
|
projectId: string;
|
|
}) {
|
|
const rows = await db
|
|
.select()
|
|
.from(rankTrackingConfigs)
|
|
.where(
|
|
and(
|
|
eq(rankTrackingConfigs.id, configId),
|
|
eq(rankTrackingConfigs.projectId, projectId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async function getConfigByProjectDomainLocation(
|
|
projectId: string,
|
|
domain: string,
|
|
locationCode: number,
|
|
locationName: string | null,
|
|
) {
|
|
const rows = await db
|
|
.select()
|
|
.from(rankTrackingConfigs)
|
|
.where(
|
|
and(
|
|
eq(rankTrackingConfigs.projectId, projectId),
|
|
eq(rankTrackingConfigs.domain, domain),
|
|
eq(rankTrackingConfigs.locationCode, locationCode),
|
|
// National (NULL) and per-city configs are distinct rows — mirrors
|
|
// the partial unique indexes, so a national config and any number of
|
|
// city configs can coexist for the same domain.
|
|
locationName === null
|
|
? isNull(rankTrackingConfigs.locationName)
|
|
: eq(rankTrackingConfigs.locationName, locationName),
|
|
),
|
|
)
|
|
.limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async function createConfig(
|
|
data: InferInsertModel<typeof rankTrackingConfigs>,
|
|
) {
|
|
await db.insert(rankTrackingConfigs).values(data);
|
|
}
|
|
|
|
async function updateConfig(
|
|
configId: string,
|
|
projectId: string,
|
|
data: Partial<InferInsertModel<typeof rankTrackingConfigs>>,
|
|
) {
|
|
await db
|
|
.update(rankTrackingConfigs)
|
|
.set(data)
|
|
.where(
|
|
and(
|
|
eq(rankTrackingConfigs.id, configId),
|
|
eq(rankTrackingConfigs.projectId, projectId),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Caps per-tick loop work (claims, per-org plan checks) against the cron
|
|
// wall clock; paid-heavy ticks are stopped earlier by the unit budget and
|
|
// slow ticks by TICK_DEADLINE_MS in scheduledRankChecks.ts.
|
|
const DUE_CONFIGS_PER_TICK = 500;
|
|
|
|
async function getDueConfigsWithOrganization(nowIso: string) {
|
|
return (
|
|
db
|
|
.select({
|
|
id: rankTrackingConfigs.id,
|
|
projectId: rankTrackingConfigs.projectId,
|
|
domain: rankTrackingConfigs.domain,
|
|
locationCode: rankTrackingConfigs.locationCode,
|
|
languageCode: rankTrackingConfigs.languageCode,
|
|
locationName: rankTrackingConfigs.locationName,
|
|
devices: rankTrackingConfigs.devices,
|
|
serpDepth: rankTrackingConfigs.serpDepth,
|
|
scheduleInterval: rankTrackingConfigs.scheduleInterval,
|
|
nextCheckAt: rankTrackingConfigs.nextCheckAt,
|
|
organizationId: projects.organizationId,
|
|
})
|
|
.from(rankTrackingConfigs)
|
|
.innerJoin(projects, eq(rankTrackingConfigs.projectId, projects.id))
|
|
.where(
|
|
and(
|
|
eq(rankTrackingConfigs.isActive, true),
|
|
// A manual config can keep a stale non-null next_check_at; without this
|
|
// it would be selected every tick and never advanced.
|
|
ne(rankTrackingConfigs.scheduleInterval, "manual"),
|
|
lte(rankTrackingConfigs.nextCheckAt, nowIso),
|
|
isNull(projects.archivedAt),
|
|
),
|
|
)
|
|
// Oldest first so a large backlog drains in order instead of the same
|
|
// arbitrary rows filling every batch. `lte` already excludes NULL, so both
|
|
// ordering columns are non-null and SQLite/Postgres agree.
|
|
.orderBy(
|
|
asc(rankTrackingConfigs.nextCheckAt),
|
|
asc(rankTrackingConfigs.id),
|
|
)
|
|
.limit(DUE_CONFIGS_PER_TICK)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Conditionally advance a due config's schedule, returning false when the
|
|
* config changed underneath us (manual edit, deactivation).
|
|
*
|
|
* `next_check_at` equality is the compare-and-set token. `schedule_interval` is
|
|
* deliberately absent from the predicate: every schedule edit rewrites
|
|
* `next_check_at` (updateConfig recomputes it, or nulls it for "manual"), so
|
|
* the timestamp check already detects interval changes.
|
|
*
|
|
* `lastSkipReason` is written only when the caller passes it — the restore
|
|
* path omits it so it can't clobber a reason the blocking run just wrote.
|
|
*/
|
|
async function claimDueConfig(input: {
|
|
configId: string;
|
|
projectId: string;
|
|
observedNextCheckAt: string;
|
|
nextCheckAt: string;
|
|
lastSkipReason?: RankTrackingSkipReason | null;
|
|
}): Promise<boolean> {
|
|
const claimed = await db
|
|
.update(rankTrackingConfigs)
|
|
.set({
|
|
nextCheckAt: input.nextCheckAt,
|
|
...(input.lastSkipReason !== undefined && {
|
|
lastSkipReason: input.lastSkipReason,
|
|
}),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(rankTrackingConfigs.id, input.configId),
|
|
eq(rankTrackingConfigs.projectId, input.projectId),
|
|
eq(rankTrackingConfigs.isActive, true),
|
|
eq(rankTrackingConfigs.nextCheckAt, input.observedNextCheckAt),
|
|
),
|
|
)
|
|
.returning({ id: rankTrackingConfigs.id });
|
|
return claimed.length > 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Run CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Try to insert a new pending run. Returns true when inserted, or false if blocked
|
|
* by the partial unique index on (config_id) WHERE status IN ('pending',
|
|
* 'running') — i.e. another active run exists for this config.
|
|
*
|
|
* This is how duplicate-trigger protection is enforced: the DB rejects the
|
|
* second insert rather than a separate lock table.
|
|
*/
|
|
async function tryCreateRun(data: {
|
|
id: string;
|
|
configId: string;
|
|
projectId: string;
|
|
keywordsTotal: number;
|
|
isSubsetRun?: boolean;
|
|
}) {
|
|
const inserted = await db
|
|
.insert(rankCheckRuns)
|
|
.values({ ...data, status: "pending" })
|
|
.onConflictDoNothing()
|
|
.returning({ id: rankCheckRuns.id });
|
|
return Boolean(inserted[0]);
|
|
}
|
|
|
|
async function updateRun(
|
|
runId: string,
|
|
data: Partial<InferInsertModel<typeof rankCheckRuns>>,
|
|
) {
|
|
await db.update(rankCheckRuns).set(data).where(eq(rankCheckRuns.id, runId));
|
|
}
|
|
|
|
async function getRunById(runId: string) {
|
|
const rows = await db
|
|
.select()
|
|
.from(rankCheckRuns)
|
|
.where(eq(rankCheckRuns.id, runId))
|
|
.limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async function getLatestRunForConfig(configId: string) {
|
|
const rows = await db
|
|
.select()
|
|
.from(rankCheckRuns)
|
|
.where(eq(rankCheckRuns.configId, configId))
|
|
.orderBy(desc(rankCheckRuns.startedAt))
|
|
.limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Returns the currently active (pending or running) run for a config, if any.
|
|
* At most one such row exists, enforced by the partial unique index.
|
|
*/
|
|
async function getActiveRunForConfig(configId: string) {
|
|
const rows = await db
|
|
.select()
|
|
.from(rankCheckRuns)
|
|
.where(
|
|
and(
|
|
eq(rankCheckRuns.configId, configId),
|
|
inArray(rankCheckRuns.status, ["pending", "running"]),
|
|
),
|
|
)
|
|
.limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Snapshots
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function insertSnapshots(
|
|
snapshots: Array<
|
|
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) =>
|
|
tx
|
|
.insert(rankSnapshots)
|
|
.values(snapshot)
|
|
.onConflictDoNothing({
|
|
target: [
|
|
rankSnapshots.runId,
|
|
rankSnapshots.trackingKeywordId,
|
|
rankSnapshots.device,
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function getSnapshotsForRun(runId: string) {
|
|
return db.select().from(rankSnapshots).where(eq(rankSnapshots.runId, runId));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tracking keywords per config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getKeywordsForConfig(configId: string) {
|
|
return db
|
|
.select()
|
|
.from(rankTrackingKeywords)
|
|
.where(eq(rankTrackingKeywords.configId, configId))
|
|
.orderBy(rankTrackingKeywords.createdAt);
|
|
}
|
|
|
|
async function addKeywordsToConfig(
|
|
keywords: Array<{ id: string; configId: string; keyword: string }>,
|
|
) {
|
|
const insertedIds: string[] = [];
|
|
|
|
// Keep each statement below D1's bound-parameter limit and return only rows
|
|
// that actually won the unique(config_id, keyword) race.
|
|
const insertBatchSize = 25;
|
|
for (let i = 0; i < keywords.length; i += insertBatchSize) {
|
|
const chunk = keywords.slice(i, i + insertBatchSize);
|
|
const inserted = await db
|
|
.insert(rankTrackingKeywords)
|
|
.values(chunk)
|
|
.onConflictDoNothing()
|
|
.returning({ id: rankTrackingKeywords.id });
|
|
insertedIds.push(...inserted.map((row) => row.id));
|
|
}
|
|
|
|
return insertedIds;
|
|
}
|
|
|
|
async function removeKeywordsFromConfig(
|
|
keywordIds: string[],
|
|
configId: string,
|
|
) {
|
|
if (keywordIds.length === 0) return [];
|
|
|
|
const removedIds: string[] = [];
|
|
// One extra bind is used by configId; keep each IN list below D1's ~100
|
|
// parameter ceiling while preserving the config ownership predicate.
|
|
const deleteBatchSize = 90;
|
|
for (let i = 0; i < keywordIds.length; i += deleteBatchSize) {
|
|
const chunk = keywordIds.slice(i, i + deleteBatchSize);
|
|
const removed = await db
|
|
.delete(rankTrackingKeywords)
|
|
.where(
|
|
and(
|
|
inArray(rankTrackingKeywords.id, chunk),
|
|
eq(rankTrackingKeywords.configId, configId),
|
|
),
|
|
)
|
|
.returning({ id: rankTrackingKeywords.id });
|
|
removedIds.push(...removed.map((row) => row.id));
|
|
}
|
|
return removedIds;
|
|
}
|
|
|
|
async function getConfigSummaries(projectId: string) {
|
|
const configs = await getConfigsForProject(projectId);
|
|
if (configs.length === 0) return [];
|
|
|
|
const kwCountMap = await getKeywordCountsForConfigs(configs.map((c) => c.id));
|
|
|
|
// Subquery: latest startedAt per config
|
|
const latestStarted = db
|
|
.select({
|
|
configId: rankCheckRuns.configId,
|
|
maxStartedAt: max(rankCheckRuns.startedAt).as("maxStartedAt"),
|
|
})
|
|
.from(rankCheckRuns)
|
|
.where(
|
|
inArray(
|
|
rankCheckRuns.configId,
|
|
configs.map((c) => c.id),
|
|
),
|
|
)
|
|
.groupBy(rankCheckRuns.configId)
|
|
.as("latestStarted");
|
|
|
|
// Join back to get status + completedAt for each config's latest run
|
|
const latestRuns = await db
|
|
.select({
|
|
configId: rankCheckRuns.configId,
|
|
status: rankCheckRuns.status,
|
|
completedAt: rankCheckRuns.completedAt,
|
|
})
|
|
.from(rankCheckRuns)
|
|
.innerJoin(
|
|
latestStarted,
|
|
and(
|
|
eq(rankCheckRuns.configId, latestStarted.configId),
|
|
eq(rankCheckRuns.startedAt, latestStarted.maxStartedAt),
|
|
),
|
|
);
|
|
|
|
const latestRunMap = new Map<
|
|
string,
|
|
{ status: string; completedAt: string | null }
|
|
>();
|
|
for (const run of latestRuns) {
|
|
latestRunMap.set(run.configId, {
|
|
status: run.status,
|
|
completedAt: run.completedAt,
|
|
});
|
|
}
|
|
|
|
return configs.map((config) => ({
|
|
...config,
|
|
keywordCount: kwCountMap.get(config.id) ?? 0,
|
|
lastRunStatus: latestRunMap.get(config.id)?.status ?? null,
|
|
lastRunCompletedAt: latestRunMap.get(config.id)?.completedAt ?? null,
|
|
}));
|
|
}
|
|
|
|
async function updateKeywordMetrics(
|
|
updates: Array<{
|
|
id: string;
|
|
searchVolume: number | null;
|
|
keywordDifficulty: number | null;
|
|
cpc: number | null;
|
|
metricsFetchedAt: string;
|
|
}>,
|
|
) {
|
|
await executeInBatches(updates, (tx, u) =>
|
|
tx
|
|
.update(rankTrackingKeywords)
|
|
.set({
|
|
searchVolume: u.searchVolume,
|
|
keywordDifficulty: u.keywordDifficulty,
|
|
cpc: u.cpc,
|
|
metricsFetchedAt: u.metricsFetchedAt,
|
|
})
|
|
.where(eq(rankTrackingKeywords.id, u.id)),
|
|
);
|
|
}
|
|
|
|
async function getKeywordCountForConfig(configId: string) {
|
|
const rows = await db
|
|
.select({ value: count() })
|
|
.from(rankTrackingKeywords)
|
|
.where(eq(rankTrackingKeywords.configId, configId));
|
|
return rows[0]?.value ?? 0;
|
|
}
|
|
|
|
/** Keyword counts keyed by config id. Configs with no keywords are absent. */
|
|
async function getKeywordCountsForConfigs(configIds: string[]) {
|
|
// Chunked so the IN list stays under D1's ~100 bound-parameter cap.
|
|
const counts = new Map<string, number>();
|
|
for (let i = 0; i < configIds.length; i += DB_BATCH_SIZE) {
|
|
const chunk = configIds.slice(i, i + DB_BATCH_SIZE);
|
|
const rows = await db
|
|
.select({ configId: rankTrackingKeywords.configId, value: count() })
|
|
.from(rankTrackingKeywords)
|
|
.where(inArray(rankTrackingKeywords.configId, chunk))
|
|
.groupBy(rankTrackingKeywords.configId);
|
|
for (const row of rows) counts.set(row.configId, row.value);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export const RankTrackingRepository = {
|
|
getConfigsForProject,
|
|
getConfigById,
|
|
getConfigByProjectDomainLocation,
|
|
createConfig,
|
|
updateConfig,
|
|
getDueConfigsWithOrganization,
|
|
claimDueConfig,
|
|
tryCreateRun,
|
|
updateRun,
|
|
getRunById,
|
|
getLatestRunForConfig,
|
|
getActiveRunForConfig,
|
|
insertSnapshots,
|
|
getSnapshotsForRun,
|
|
getKeywordsForConfig,
|
|
addKeywordsToConfig,
|
|
removeKeywordsFromConfig,
|
|
updateKeywordMetrics,
|
|
getKeywordCountForConfig,
|
|
getKeywordCountsForConfigs,
|
|
getConfigSummaries,
|
|
getLatestSnapshotsForKeywords,
|
|
getSnapshotsBeforeDate,
|
|
getEarliestSnapshotsForKeywords,
|
|
getKeywordHistory,
|
|
getConfigTrend,
|
|
getPositionMatrix,
|
|
};
|