feat(rank-tracking): raise scheduler throughput 5x with rate-limit-derived sizing (#465)
* 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.
This commit is contained in:
parent
16b6d38ea8
commit
eec998a762
@ -188,15 +188,20 @@ export default {
|
|||||||
env: Env,
|
env: Env,
|
||||||
_ctx: ExecutionContext,
|
_ctx: ExecutionContext,
|
||||||
) {
|
) {
|
||||||
// Scope a per-request Postgres client for the cron run (no-op in D1 mode).
|
// Watchdog first: reconcile audits stuck in "running" whose workflow died
|
||||||
// Caught so a rank-tracking failure can't suppress the audit watchdog below.
|
// without reaching mark-failed (OOM/CPU kills, expired instances). Runs
|
||||||
|
// before the rank loop so a slow tick can't delay or starve it. Its
|
||||||
|
// failure is held until after the rank checks so it can't suppress them,
|
||||||
|
// then rethrown so the invocation still reports as failed.
|
||||||
|
let watchdogError: unknown;
|
||||||
try {
|
try {
|
||||||
await withPgClient(() => runScheduledRankChecks(env));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[cron] Scheduled rank checks failed:", err);
|
|
||||||
}
|
|
||||||
// Watchdog: reconcile audits stuck in "running" whose workflow died
|
|
||||||
// without reaching mark-failed (OOM/CPU kills, expired instances).
|
|
||||||
await withPgClient(() => reconcileStaleAudits());
|
await withPgClient(() => reconcileStaleAudits());
|
||||||
|
} catch (err) {
|
||||||
|
watchdogError = err;
|
||||||
|
console.error("[cron] Stale-audit reconcile failed:", err);
|
||||||
|
}
|
||||||
|
// Scope a per-request Postgres client for the cron run (no-op in D1 mode).
|
||||||
|
await withPgClient(() => runScheduledRankChecks(env));
|
||||||
|
if (watchdogError) throw watchdogError;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -115,6 +115,11 @@ async function updateConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
async function getDueConfigsWithOrganization(nowIso: string) {
|
||||||
return (
|
return (
|
||||||
db
|
db
|
||||||
@ -150,7 +155,7 @@ async function getDueConfigsWithOrganization(nowIso: string) {
|
|||||||
asc(rankTrackingConfigs.nextCheckAt),
|
asc(rankTrackingConfigs.nextCheckAt),
|
||||||
asc(rankTrackingConfigs.id),
|
asc(rankTrackingConfigs.id),
|
||||||
)
|
)
|
||||||
.limit(200)
|
.limit(DUE_CONFIGS_PER_TICK)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MAX_KEYWORDS_PER_CONFIG } from "@/shared/rank-tracking";
|
||||||
|
|
||||||
type DueConfigRow = {
|
type DueConfigRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -177,15 +178,16 @@ describe("runScheduledRankChecks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stops admitting configs once the task-unit budget is spent", async () => {
|
it("stops admitting configs once the task-unit budget is spent", async () => {
|
||||||
// 400 keywords × 2 devices = 800 units — admitted anyway as the tick's
|
// A legal-max config (MAX_KEYWORDS_PER_CONFIG × both devices) exceeds the
|
||||||
// first start (oversized exemption), then exhausts the 200-unit budget.
|
// whole budget but is admitted as the tick's first start (oversized
|
||||||
|
// exemption); the next config then hits the budget stop.
|
||||||
mocks.getDueConfigsWithOrganization.mockResolvedValue([
|
mocks.getDueConfigsWithOrganization.mockResolvedValue([
|
||||||
dueConfig({ id: "config_big" }),
|
dueConfig({ id: "config_big" }),
|
||||||
dueConfig({ id: "config_next", nextCheckAt: "2026-01-02T00:00:00.000Z" }),
|
dueConfig({ id: "config_next", nextCheckAt: "2026-01-02T00:00:00.000Z" }),
|
||||||
]);
|
]);
|
||||||
mocks.getKeywordCountsForConfigs.mockResolvedValue(
|
mocks.getKeywordCountsForConfigs.mockResolvedValue(
|
||||||
new Map([
|
new Map([
|
||||||
["config_big", 400],
|
["config_big", MAX_KEYWORDS_PER_CONFIG],
|
||||||
["config_next", 5],
|
["config_next", 5],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@ -198,7 +200,10 @@ describe("runScheduledRankChecks", () => {
|
|||||||
expect(mocks.claimDueConfig).toHaveBeenCalledTimes(1);
|
expect(mocks.claimDueConfig).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.customerHasPaidPlan).toHaveBeenCalledTimes(1);
|
expect(mocks.customerHasPaidPlan).toHaveBeenCalledTimes(1);
|
||||||
expect(logSpy).toHaveBeenCalledWith(
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ stoppedByBudget: true, unitsStarted: 800 }),
|
expect.objectContaining({
|
||||||
|
stoppedByBudget: true,
|
||||||
|
unitsStarted: MAX_KEYWORDS_PER_CONFIG * 2,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -252,8 +257,9 @@ describe("runScheduledRankChecks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defers a config whose units would overflow the remaining budget", async () => {
|
it("defers a config whose units would overflow the remaining budget", async () => {
|
||||||
// First config fits (10 units); the second would overflow (800), so it is
|
// First config fits (10 units); the second (legal-max, over the whole
|
||||||
// deferred rather than admitted just because budget remains.
|
// budget) would overflow, so it is deferred rather than admitted just
|
||||||
|
// because budget remains.
|
||||||
mocks.getDueConfigsWithOrganization.mockResolvedValue([
|
mocks.getDueConfigsWithOrganization.mockResolvedValue([
|
||||||
dueConfig({ id: "config_small" }),
|
dueConfig({ id: "config_small" }),
|
||||||
dueConfig({ id: "config_big", nextCheckAt: "2026-01-02T00:00:00.000Z" }),
|
dueConfig({ id: "config_big", nextCheckAt: "2026-01-02T00:00:00.000Z" }),
|
||||||
@ -261,7 +267,7 @@ describe("runScheduledRankChecks", () => {
|
|||||||
mocks.getKeywordCountsForConfigs.mockResolvedValue(
|
mocks.getKeywordCountsForConfigs.mockResolvedValue(
|
||||||
new Map([
|
new Map([
|
||||||
["config_small", 5],
|
["config_small", 5],
|
||||||
["config_big", 400],
|
["config_big", MAX_KEYWORDS_PER_CONFIG],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
@ -277,6 +283,26 @@ describe("runScheduledRankChecks", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stops the tick at the wall-clock deadline instead of running long", async () => {
|
||||||
|
mocks.getDueConfigsWithOrganization.mockResolvedValue([
|
||||||
|
dueConfig({ id: "config_1" }),
|
||||||
|
dueConfig({ id: "config_2", nextCheckAt: "2026-01-02T00:00:00.000Z" }),
|
||||||
|
]);
|
||||||
|
const start = Date.now();
|
||||||
|
vi.spyOn(Date, "now")
|
||||||
|
.mockReturnValueOnce(start) // deadline anchor
|
||||||
|
.mockReturnValue(start + 4 * 60_000); // every later check is past it
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runTick();
|
||||||
|
|
||||||
|
expect(mocks.beginRankCheckRun).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.claimDueConfig).not.toHaveBeenCalled();
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ stoppedByDeadline: true, started: 0 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("restores the original due time when a run is already active", async () => {
|
it("restores the original due time when a run is already active", async () => {
|
||||||
mocks.beginRankCheckRun.mockResolvedValue({
|
mocks.beginRankCheckRun.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@ -10,9 +10,24 @@ import {
|
|||||||
|
|
||||||
// Work admitted per tick, in task units (keywords × devices). Admission
|
// Work admitted per tick, in task units (keywords × devices). Admission
|
||||||
// control, not a hard rate limit: the first start of a tick is always
|
// control, not a hard rate limit: the first start of a tick is always
|
||||||
// admitted, so an oversized config can never starve. 200 units on the
|
// admitted, so a config bigger than the budget (legal max: 1,000 keywords ×
|
||||||
// 5-minute cron ≈ 57,600 units/day, ~9× current steady-state demand.
|
// 2 devices = 2,000 units) can never starve. Sized against DataForSEO's
|
||||||
const SCHEDULED_TASK_UNIT_BUDGET = 200;
|
// 2,000 requests/min account cap, where task_get polling is the binding
|
||||||
|
// term: one call per unit per poll round, rounds wake synchronized per tick,
|
||||||
|
// and up to three ticks' ~15-minute poll windows overlap the */5 cron — so a
|
||||||
|
// full tick can burst ~1,000 polls into a single minute, stacking with the
|
||||||
|
// residual rounds of the two prior ticks. Overruns aren't
|
||||||
|
// loud failures: throttled polls age into the live fallback at ~3× cost,
|
||||||
|
// billed to the customer, so keep real headroom under the cap.
|
||||||
|
// 1,000/tick ≈ 288,000 units/day, ~45× steady-state demand — it only binds
|
||||||
|
// during backlog catch-up.
|
||||||
|
const SCHEDULED_TASK_UNIT_BUDGET = 1000;
|
||||||
|
|
||||||
|
// Wall-clock guard for the per-config loop: sub-hourly crons are killed at 15
|
||||||
|
// minutes, and a skip-heavy tick pays serial Autumn round-trips per distinct
|
||||||
|
// org (worst case minutes, more when Autumn is degraded). Stopping early is
|
||||||
|
// safe — unprocessed configs stay due and the next tick resumes oldest-first.
|
||||||
|
const TICK_DEADLINE_MS = 3 * 60_000;
|
||||||
|
|
||||||
// Cap on the per-tick list of configs blocked by an active run. Blocked
|
// Cap on the per-tick list of configs blocked by an active run. Blocked
|
||||||
// configs leave no durable trace on their row, so the summary names them.
|
// configs leave no durable trace on their row, so the summary names them.
|
||||||
@ -43,9 +58,11 @@ export async function runScheduledRankChecks(env: Env) {
|
|||||||
return check;
|
return check;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deadline = Date.now() + TICK_DEADLINE_MS;
|
||||||
let unitsStarted = 0;
|
let unitsStarted = 0;
|
||||||
let started = 0;
|
let started = 0;
|
||||||
let stoppedByBudget = false;
|
let stoppedByBudget = false;
|
||||||
|
let stoppedByDeadline = false;
|
||||||
let skippedFree = 0;
|
let skippedFree = 0;
|
||||||
let skippedNoKeywords = 0;
|
let skippedNoKeywords = 0;
|
||||||
let concurrentChangeSkips = 0;
|
let concurrentChangeSkips = 0;
|
||||||
@ -56,6 +73,10 @@ export async function runScheduledRankChecks(env: Env) {
|
|||||||
let configErrors = 0;
|
let configErrors = 0;
|
||||||
|
|
||||||
for (const config of dueConfigs) {
|
for (const config of dueConfigs) {
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
stoppedByDeadline = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
// Per-config containment: one bad row (e.g. malformed next_check_at, which
|
// Per-config containment: one bad row (e.g. malformed next_check_at, which
|
||||||
// sorts first and would head every scan) or transient DB error must not
|
// sorts first and would head every scan) or transient DB error must not
|
||||||
// starve the rest of the tick or suppress the summary log.
|
// starve the rest of the tick or suppress the summary log.
|
||||||
@ -219,6 +240,7 @@ export async function runScheduledRankChecks(env: Env) {
|
|||||||
unitsStarted,
|
unitsStarted,
|
||||||
budget: SCHEDULED_TASK_UNIT_BUDGET,
|
budget: SCHEDULED_TASK_UNIT_BUDGET,
|
||||||
stoppedByBudget,
|
stoppedByBudget,
|
||||||
|
stoppedByDeadline,
|
||||||
skippedFree,
|
skippedFree,
|
||||||
skippedNoKeywords,
|
skippedNoKeywords,
|
||||||
concurrentChangeSkips,
|
concurrentChangeSkips,
|
||||||
|
|||||||
@ -166,7 +166,7 @@ const QUEUED_POLL_INTERVALS = [
|
|||||||
/** Concurrent task_get requests within a collect step. */
|
/** Concurrent task_get requests within a collect step. */
|
||||||
const TASK_GET_CONCURRENCY = 25;
|
const TASK_GET_CONCURRENCY = 25;
|
||||||
|
|
||||||
/** Max task_get calls per collect round (per-invocation subrequest budget). */
|
/** Max task_get calls per collect round (bounds one round's fan-out). */
|
||||||
const TASK_GETS_PER_COLLECT = 500;
|
const TASK_GETS_PER_COLLECT = 500;
|
||||||
|
|
||||||
// Collect steps may issue hundreds of task_get calls, so they get more room
|
// Collect steps may issue hundreds of task_get calls, so they get more room
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user