âą Scheduled Cron Jobs
What the backend does on a schedule â counter sync from KV to D1, and ranking recalculation across all tools. Defined in wrangler.toml, executed by the scheduled() handler in src/index.ts.
How It Works
Cloudflare Workers supports scheduled triggers via cron syntax defined in wrangler.toml. When the trigger fires, Cloudflare calls the scheduled() handler exported from the worker instead of the normal fetch() handler. It runs entirely server-side â no HTTP request, no response, no client involved.
export default {
fetch: app.fetch,
async scheduled(_event, env, ctx): Promise<void> {
ctx.waitUntil(
// Step 1 â flush KV counters into D1
syncCountersToDb(env)
.then((result) => logger.info("Cron: counter sync complete", result))
.catch((err) => logger.error("Cron: counter sync failed", { error: String(err) }))
// Step 2 â recalculate all tool rankings with fresh D1 counts
.then(() => recalculateAllRankings(env))
.then(({ updated }) => logger.info("Cron: ranking recalculation complete", { updated }))
.catch((err) => logger.error("Cron: ranking recalculation failed", { error: String(err) }))
);
},
};
ctx.waitUntil() keeps the Worker alive until the promise chain resolves, even though there is no HTTP response to send. Without it, the Worker would terminate immediately after the handler returns.
Schedule
| Cron expression | Meaning | Where to change |
|---|---|---|
| */30 * * * * | Every 30 minutes | wrangler.toml â [triggers] â crons |
[triggers] crons = ["*/30 * * * *"] # Other useful values: # "*/15 * * * *" â every 15 minutes # "0 * * * *" â every hour # "0 2 * * *" â every day at 02:00 UTC # "0 2 * * 0" â every Sunday at 02:00 UTC
wrangler deploy to push the new schedule. The change takes effect immediately on the next cron tick.
Step 1 â Counter Sync (KV â D1)
Views and clicks are buffered in Cloudflare KV on each request (fast write, no D1 pressure). The cron job is the only thing that moves those counts into D1 permanently. This step always runs first so that the ranking recalculation in Step 2 uses up-to-date counts.
List all buffered keys
kv.list({ prefix: "v:" }) and kv.list({ prefix: "c:" }) â fetches all tool IDs that have pending counts. Tools with no buffered events have no KV key and are skipped entirely.
Read and immediately delete the KV key
The key is deleted before the D1 write. This prevents double-counting if the cron fires again before the D1 write completes (e.g. retries). The count value is held in memory for the next step.
Increment D1 counter
prisma.tool.update({ data: { viewCount: { increment: count } } }) â adds the buffered value atomically. Not a SET â it increments, so any counts that arrived since the KV read are safe.
Restore on D1 failure
If the D1 update throws, the KV key is written back with the saved count. The next cron run will retry it. No counts are lost.
{
"views": 14, // number of tool IDs with buffered view counts
"clicks": 6, // number of tool IDs with buffered click counts
"synced": 19, // successful D1 updates
"failed": 1 // D1 failures (KV key restored for retry)
}
Step 2 â Ranking Recalculation
After counters are flushed into D1, recalculateAllRankings() re-scores every non-archived tool using the updated counts. This ensures the listing order always reflects the latest engagement data.
Fetch all non-archived tools in batches of 100
Uses cursor-based pagination to avoid D1 query limits. Selects only the fields needed for the formula: upvoteCount, clickCount, viewCount, rating, reviewCount, featured, verified, trending, publishedAt.
Compute rankScore for each tool
Applies the ranking formula â computeRankScore(row). The recency boost (30 / (daysSincePublished + 1)) naturally decays on every run, so older tools gradually give way to newer ones with similar engagement.
Write rankScore back to D1
prisma.tool.update({ data: { rankScore: score } }) â one update per tool. After all batches complete, invalidateToolCache() is called to bump the KV cache version so the next listing request fetches fresh data from D1.
rankScore = upvoteCount à 4.0 // strongest signal + clickCount à 2.5 // outbound intent + viewCount à 0.05 // dampened â easy to game + rating à reviewCount à 3.0 + (featured ? 50 : 0) + (verified ? 25 : 0) + (trending ? 30 : 0) + 30 / (daysSincePublished + 1) // recency decay
Failure Handling
| Failure scenario | What happens | Data lost? |
|---|---|---|
| D1 write fails on counter sync | KV key restored with original count. Next cron run retries the flush. | No |
| KV restore also fails | Count for that tool is logged as failed and dropped. | Possible (rare) |
| Ranking recalc fails for a tool | That tool keeps its previous rankScore. Next cron run retries all tools. | No |
| Counter sync step throws entirely | .catch() logs the error; ranking recalc is still attempted (chained with .then() after catch). | Possible |
| Worker CPU limit exceeded | Cloudflare terminates the handler. Partial flush may have occurred â remaining KV keys are retried next run. | No â KV keys still present |
Testing Locally
wrangler dev --test-scheduled
curl "http://localhost:8787/cdn-cgi/handler/scheduled"
Workers â your worker â Triggers tab â "Test" button next to the cron
Changing the Schedule
Only change the cron string in wrangler.toml â no code change needed. Redeploy to apply.
| Cron string | Frequency | When to use |
|---|---|---|
| */15 * * * * | Every 15 min | High traffic â counts visible sooner in rankings |
| */30 * * * * | Every 30 min | Default â good balance |
| 0 * * * * | Every hour | Low traffic â fewer D1 writes |
| 0 2 * * * | Daily at 02:00 UTC | Minimal traffic â rankings update once a day |