Background jobs and scheduling
Cloudflare Pages never calls scheduled(), so background work is driven by an external clock that POSTs to a secured endpoint, and every job self-throttles in the database.
Last updated 2026-08-06
Summary#
_worker.js exports a scheduled() handler, and it never runs. Cloudflare only invokes scheduled() on a project deployed as a Worker with a [triggers] crons block, and this project deploys to Pages. Background work is therefore driven from outside: a separate, tiny Cloudflare Worker called metricvault-cron fires every 15 minutes and POSTs to /api/cron/run on the Pages deployment, and a GitHub Actions workflow does the same thing hourly as a backup.
That endpoint runs eight registered jobs. Every one of them decides for itself what is due by reading a timestamp out of D1, so calling the endpoint more often than necessary is harmless. Running both schedulers at once is deliberate, not an accident.
Purpose#
The product needs work that happens without a user: competitor pages must be re-fetched, rank positions re-checked, AI-visibility measurements taken, Stripe entitlements reconciled, saved results purged at 90 days. None of that can hang off a request.
The obvious answer, a cron trigger, is unavailable. Rather than move the whole site to a Workers deploy for one feature, the design splits the clock from the work: the clock is a Worker (12 lines of real logic, no bindings, no database), and the work stays in the Pages worker where the data and the code already are. The cost of that split is one extra deploy and one shared secret. The benefit is that the site keeps its Pages deployment model, and the jobs are reachable by anything that can make an authenticated HTTP request, which makes them testable by hand.
Architecture#
metricvault-cron (a real Cloudflare Worker)
[triggers] crons = ["*/15 * * * *"] PRIMARY
│ POST {TARGET_URL}/api/cron/run
│ header: x-mv-internal-secret
▼
.github/workflows/cron.yml BACKUP
schedule: "17 * * * *" (hourly, minute 17)
│ curl -X POST "$TARGET/api/cron/run"
▼
┌───────────────────────────────────────────────────────┐
│ _worker.js → handleCronRun │
│ mvVerifyInternalSecret (503 unset / 401 mismatch)│
│ globalThis.__mvEnv = env │
│ ?job=<key> or all nineteen │
│ ctx.waitUntil(Promise.all(jobs)) │
│ return 200 { ok, triggered[], at } IMMEDIATELY │
└───────────────────────────────────────────────────────┘
│
▼ each job reads its own "due" predicate from D1
monitor · schedules · rank_alerts · tier2 · crawl · sweep_orphans
purge_results · purge_watchtower · sweep_caches · higgsfield · social_schedule
blog_schedule · cms_schedule · billing_sync · google_sync · ga4_sync
gsc_inspect · gsc_vitals · backlink_watchWhy Pages never calls scheduled()#
The handler exists in the default export alongside fetch, and it runs four jobs inside ctx.waitUntil. The comment directly above it records why it is dead code that is kept anyway: Cloudflare only calls it when the project is deployed as a Worker with a [triggers] crons block, and this repository deploys to Pages. It stays so that the same source would work unchanged if the project were ever redeployed as a Worker.
Three facts back this up, and each is worth knowing before someone tries to "fix" it:
wrangler.tomlcarriespages_build_output_dir = "dist", which is the Pages format. Changing it to the Workers format breaks the bindings. See System architecture.- The header comment in
wrangler.tomlrecords that a previous version declared a six-hourly cron (0 */6 * * *) which never took effect. - The deploy workflow runs
wrangler pages deploy.
Warning: do not add a [triggers] crons block to the root wrangler.toml. It will not run, and the silence looks exactly like a working scheduler that has nothing to do.
The primary clock: cron-worker/#
A standalone Worker with its own wrangler.toml, its own deploy, and no storage bindings at all. That is deliberate: it holds no business logic, so it can never be the reason a job is wrong.
| Property | Value |
|---|---|
| Worker name | metricvault-cron |
| Entrypoint | worker.js |
| Cron | */15 * * * * |
| Var | TARGET_URL = "https://metricvaultai.com" |
| Secret | MV_INTERNAL_SECRET, set with wrangler secret put MV_INTERNAL_SECRET |
| Deploy | cd cron-worker && wrangler deploy |
| Logs | wrangler tail metricvault-cron |
It also exports a fetch handler, so a plain GET to the worker runs the same trigger and returns the JSON result. That is the manual run and health check. The response is { ok, status, url, body, at }, where body is the first 500 characters of the site's reply; status is 200 when ok and 502 otherwise.
If the secret is missing it refuses to call the site at all and returns MV_INTERNAL_SECRET is not set on this cron worker. Run: wrangler secret put MV_INTERNAL_SECRET.
Changing the schedule means editing crons in cron-worker/wrangler.toml and redeploying that worker. It is not affected by a Pages deploy, and a Pages deploy does not update it. See Deployment.
The backup: GitHub Actions#
.github/workflows/cron.yml, named Scheduled jobs (competitor monitor, reports, rank alerts, AI tracking), runs at 17 * * * *. Its concurrency group is scheduled-jobs with cancel-in-progress: false, so two runs never overlap.
It targets ${{ vars.CRON_TARGET_URL || 'https://metricvaultai.com' }} and uses ${{ secrets.MV_INTERNAL_SECRET }}. It fails loudly rather than silently:
| Condition | Annotation |
|---|---|
| Secret empty | ::error::MV_INTERNAL_SECRET repo secret is not set. Add it and match it in Cloudflare Pages env. |
| curl could not connect | ::error::curl failed to reach <url> |
| Any status other than 200 | ::error::Cron trigger returned HTTP <code> (expected 200). |
| Success | Scheduled jobs triggered successfully. |
workflow_dispatch accepts an optional job input for a manual single-job run. Its help text reads Single job to run (blank = all): monitor | schedules | rank_alerts | tier2 | crawl, which lists only four of the eight valid keys. The other four still work if you type them.
The shared secret#
MV_INTERNAL_SECRET must hold the same value in three places:
- The Cloudflare Pages project
metricvaultai, under Settings, Environment variables. - The
cron-workerWorker, viawrangler secret put MV_INTERNAL_SECRET. - GitHub repository secrets, for the backup workflow.
The verifier mvVerifyInternalSecret fails closed:
| Condition | Response | Status |
|---|---|---|
MV_INTERNAL_SECRET unset or empty | {"error":"Internal jobs are not configured (MV_INTERNAL_SECRET is not set)."} | 503 |
| Presented value does not match | {"error":"Unauthorized internal call"} | 401 |
| Match | proceeds |
The secret is read from the x-mv-internal-secret header, or from a ?key= query parameter when the header is absent. There is deliberately no guessable fallback: an older mv-internal-job-<day> scheme was removed because anyone could derive it. The same verifier also guards /api/billing/sync, which is why a missing secret also stops plans reconciling against Stripe. Full variable inventory in Environment variables and secrets.
Components#
POST or GET /api/cron/run reaches handleCronRun. The job registry:
?job= key | Function | What it does | In scheduled()? | In the admin console? |
|---|---|---|---|---|
monitor | runMonitorCron | Re-fetches due monitored URLs, snapshots them, diffs against the previous snapshot, dispatches alerts | Yes | Yes |
schedules | runDueSchedules | Inserts a queued row per due scheduled report and optionally emails the recipient | Yes | Yes |
rank_alerts | runDueRankAlerts | Re-checks keyword positions and fires drop alerts | Yes | Yes |
tier2 | runTier2Tracking | Measures tracked brands across the LLM engines and stores history | Yes | Yes |
crawl | runCrawlJobs | Advances any site crawl that is still running, one bounded slice per pass | Yes | Yes |
sweep_orphans | mvSweepOrphans | Clears rows whose website_id names a website that no longer exists. See Working with multiple websites | No | No |
purge_results | mvPurgeToolResults | Deletes tool_results older than 90 days | No | No |
purge_watchtower | mvPurgeWatchtower | Chunked retention pass over the Competitor Monitor tables | No | No |
sweep_caches | mvSweepCaches | Deletes expired rows from the nine cache tables, each on its own TTL | No | No |
higgsfield | mvbHiggsKeepAlive | Renews the Higgsfield provider token before it expires | No | No |
social_schedule | mvRunDueSocialPosts | Publishes social posts whose time has arrived | No | No |
blog_schedule | mvbPublishDuePosts | Flips scheduled blog posts to published. Nothing else moves a post out of scheduled | No | No |
cms_schedule | runDueCmsPublishes | Publishes CMS posts (WordPress, Ghost, Webflow) whose scheduled time has arrived. Claims each row before the call; three attempts then it holds the error | No | Yes |
billing_sync | runStripeBillingSync | Reconciles user_plans against live Stripe subscriptions | No | No |
google_sync | mvGscSyncCron | Pulls Search Console into gsc_daily, self-throttling on gsc_properties.last_sync_at | No | No |
ga4_sync | mvGa4SyncCron | Pulls GA4 into ga4_daily, self-throttling on ga4_properties.last_sync_at | No | No |
gsc_inspect | mvGscInspectCron | Index status for the top pages of each property. 10 URLs per run, refreshed every 14 days, because Google allows 2,000 inspections a day per property | No | No |
gsc_vitals | mvCwvCron | Core Web Vitals field data, the origin plus 5 pages per run, refreshed every 3 days. CrUX first, PageSpeed as the fallback | No | No |
backlink_watch | mvBacklinkWatchCron | Weekly referring-domain diff per website, 3 sites per run. One paid provider request each, so it is deliberately small | No | No |
Behavior of the handler:
- Stashes
envonglobalThis.__mvEnv, becausedispatchAlertsneedsRESEND_API_KEYand is called deep insidecheckSingleUrlwith noenvparameter. - Reads
?job=. Blank runs everything. - An unknown key returns HTTP 400 with
{"error":"Unknown job: <name>","valid":[...]}. - Each job is wrapped so one failure cannot abort the others. A throw is logged as
[cron] <name> failed: <message>toconsole.error. - The work is handed to
ctx.waitUntil, so it keeps running after the response. - The response is
{"ok":true,"triggered":[...],"at":"<ISO 8601>"}, returned before the jobs finish.
Important: HTTP 200 from /api/cron/run means triggered, not succeeded. Both schedulers treat it as success. Job failures surface only in wrangler tail, not in any dashboard.
The endpoint is declared in MV_ACT_ROUTES as { cat:'system', action:'cron.ran', title:'Scheduled jobs ran', module:'Automation', bucket:'hour' }, so it appears in the Library as Scheduled jobs ran, bucketed hourly.
Data flow#
Idempotency#
This is the property that makes a 15-minute tick safe. No job runs simply because it was called; each one selects only the rows that are due.
| Job | Throttle |
|---|---|
monitor | last_checked_at older than check_interval_hours * 3600, LIMIT 50. Never-checked rows sort first |
schedules | next_run_at <= now, LIMIT 20 |
rank_alerts | last_checked_at older than 86,400 seconds, LIMIT 30 |
tier2 | tier2_meta.last_run_at, MIN_INTERVAL_SECONDS = 22 * 3600; up to 5 brands, 8 prompts each |
social_schedule | Atomic claim pending → sending guarded by WHERE id = ? AND status = 'pending', LIMIT 25. Two overlapping runs cannot double-send |
higgsfield | Skips unless the access token expires within 6 hours |
purge_results | A pure DELETE ... WHERE created_at < now - 90d |
sweep_orphans | website_id IS NOT NULL AND website_id NOT IN (SELECT id FROM mv_websites). Idempotent: on a clean database it matches nothing |
billing_sync | Writes only when the tier actually changes |
Two of these deserve a closer look because their safety is not just a WHERE clause.
social_schedule claims each post with an UPDATE whose WHERE includes the old status, then checks meta.changes. If another run already claimed the row, changes is 0 and the loop skips it. A post is marked sent if any channel succeeded.
billing_sync grants first and revokes second, and a revoke only ever touches a row this system wrote (set_by = 'stripe'), so manually comped accounts and admin overrides are never clobbered. It pages through Stripe subscriptions with MAX_PAGES = 40, and if pagination did not complete it grants only and reports revoke_skipped: 'incomplete scan — granted only, no revocations'.
The failure path per job#
checkSingleUrl stamps last_checked_at even when the fetch throws, so a permanently broken URL is not retried every tick. runDueSchedules writes a schedule_runs row with status = 'error' and the message. runDueRankAlerts pushes { id, error } into its results rather than aborting the batch.
Manual and operator triggers#
| How | What it runs |
|---|---|
curl -X POST https://metricvaultai.com/api/cron/run -H "x-mv-internal-secret: $SECRET" | All eight |
Add ?job=tier2 | One job |
curl https://metricvault-cron.<subdomain>.workers.dev | The cron worker's own fetch, which triggers all eight |
| GitHub Actions, Run workflow | The backup path, optionally one job |
| Admin console, Run due now | Four jobs only, owner-only |
The admin console exposes monitor, schedules, rank_alerts and tier2 through POST /api/admin/jobs/run. A non-owner admin gets {"error":"Only an owner can run jobs."}. It calls the same functions the cron runner uses, so it is not a forced re-run: the job still processes only what is due, and tier-2 still respects its 22-hour throttle. Every use writes an audit row. See Background jobs console.
Failure modes#
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Secret missing in Pages | /api/cron/run returns 503; AI article generation returns 503 | MV_INTERNAL_SECRET unset in the Pages environment | Set it in all three places |
| Secret mismatched | GitHub annotates Cron trigger returned HTTP 401 (expected 200). | The three copies drifted | Re-set all three to one value |
| Cron worker never deployed | Nothing runs on the 15-minute cadence | It is a separate deploy and a Pages deploy does not touch it | cd cron-worker && wrangler deploy |
| A job throws | Only a [cron] <name> failed line in the tail | Jobs are wrapped so the others survive | wrangler tail, then fix the job |
| Tier-2 silently does nothing | tier2_meta.last_run_at still advances | runTier2Tracking returns immediately without OPENAI_API_KEY, and its body swallows every error | Check the key; the admin console shows OPENAI_API_KEY not set — this job is a no-op. |
| "Last run" looks wrong | The console shows an item timestamp, not a job timestamp | Only tier-2 records a true job-level last run; the others infer it from the newest processed row | Expected. A run that processed nothing is indistinguishable from no run |
| Two schedulers both fire | Nothing bad | Jobs self-throttle | Expected and intended |
Two behaviors are documented here because they surprise people, not because they are bugs to fix casually:
- Scheduled reports do not execute a workflow.
runDueSchedulesinserts aqueuedrow and optionally sends an email; the comment explains that running AI workflows on the cron would mean large CPU cost, so the frontend shows the run rows and the user clicks to execute. The email copy nonetheless reads as though a report was produced. computeNextRunmatches cadence exactly and case-sensitively. Onlydaily,weeklyandmonthlymap; everything else falls through to weekly.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.