mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 13:21:31 +00:00
docs: add D1 and Cron guides, update module contract across docs
- docs/using-d1.md and docs/using-cron.md for module authors - architecture, codebase-summary, adding-a-module, code-standards, deployment-guide refreshed - CLAUDE.md module contract shows optional crons[] and sql in init - docs/todo.md tracks manual follow-ups (D1 UUID, first deploy, smoke tests)
This commit is contained in:
283
docs/using-cron.md
Normal file
283
docs/using-cron.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# Using Cron (Scheduled Jobs)
|
||||
|
||||
Cron allows modules to run scheduled tasks at fixed intervals. Use crons for cleanup (purge old data), maintenance (recompute stats), or periodic notifications.
|
||||
|
||||
## Declaring Crons
|
||||
|
||||
In your module's default export, add a `crons` array:
|
||||
|
||||
```js
|
||||
export default {
|
||||
name: "mymod",
|
||||
init: async ({ db, sql, env }) => { /* ... */ },
|
||||
commands: [ /* ... */ ],
|
||||
crons: [
|
||||
{
|
||||
schedule: "0 17 * * *", // 5 PM UTC daily
|
||||
name: "cleanup", // human-readable identifier
|
||||
handler: async (event, ctx) => {
|
||||
// event.cron = "0 17 * * *"
|
||||
// event.scheduledTime = timestamp (ms)
|
||||
// ctx = { db, sql, env } (same as module init)
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
**Handler signature:**
|
||||
|
||||
```js
|
||||
async (event, { db, sql, env }) => {
|
||||
// event.cron — the schedule string that fired
|
||||
// event.scheduledTime — Unix timestamp (ms)
|
||||
// db — namespaced KV store (same as init)
|
||||
// sql — namespaced D1 store (same as init), null if not bound
|
||||
// env — raw worker environment
|
||||
}
|
||||
```
|
||||
|
||||
## Cron Expression Syntax
|
||||
|
||||
Standard 5-field cron format (minute, hour, day-of-month, month, day-of-week):
|
||||
|
||||
```
|
||||
minute hour day-of-month month day-of-week
|
||||
0-59 0-23 1-31 1-12 0-6 (0=Sunday)
|
||||
|
||||
"0 17 * * *" — 5 PM UTC daily
|
||||
"*/5 * * * *" — every 5 minutes
|
||||
"0 0 1 * *" — midnight on the 1st of each month
|
||||
"0 9 * * 1" — 9 AM UTC every Monday
|
||||
"30 2 * * *" — 2:30 AM UTC daily
|
||||
```
|
||||
|
||||
See Cloudflare's [cron expression docs](https://developers.cloudflare.com/workers/configuration/cron-triggers/) for full syntax.
|
||||
|
||||
## Registering in wrangler.toml
|
||||
|
||||
**Important:** Crons declared in the module MUST also be listed in `wrangler.toml`. This is because Cloudflare needs to know what schedules to fire at deploy time.
|
||||
|
||||
Edit `wrangler.toml`:
|
||||
|
||||
```toml
|
||||
[triggers]
|
||||
crons = ["0 17 * * *", "0 0 * * *", "*/5 * * * *"]
|
||||
```
|
||||
|
||||
Both the module contract and the `[triggers] crons` array must list the same schedules. If a schedule is in the module but not in `wrangler.toml`, Cloudflare won't fire it. If it's in `wrangler.toml` but not in any module, the worker won't know what to do with it.
|
||||
|
||||
**Multiple modules can share a schedule** — all matching handlers will fire (fan-out). Each module must declare its own `crons` entry; the registry validates them at load time.
|
||||
|
||||
## Handler Details
|
||||
|
||||
### Error Isolation
|
||||
|
||||
If one handler fails, other handlers still run. Each handler is wrapped in try/catch:
|
||||
|
||||
```js
|
||||
// In cron-dispatcher.js
|
||||
for (const entry of matching) {
|
||||
ctx.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
await entry.handler(event, handlerCtx);
|
||||
} catch (err) {
|
||||
console.error(`[cron] handler failed:`, err);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Errors are logged to the Workers console but don't crash the dispatch loop.
|
||||
|
||||
### Execution Time Limits
|
||||
|
||||
Cloudflare cron tasks have a **15-minute wall-clock limit**. Operations exceeding this timeout are killed. For large data operations:
|
||||
|
||||
- Batch in chunks (e.g., delete 1000 rows at a time, looping)
|
||||
- Use pagination to avoid loading entire datasets into memory
|
||||
- Monitor execution time and add logging
|
||||
|
||||
### Context Availability
|
||||
|
||||
Cron handlers run in the same Worker runtime as HTTP handlers, so they have access to:
|
||||
|
||||
- `db` — the module's namespaced KV store (read/write)
|
||||
- `sql` — the module's namespaced D1 store (read/write), or null if not configured
|
||||
- `env` — all Worker environment bindings (secrets, etc.)
|
||||
|
||||
### Return Value
|
||||
|
||||
Handlers should return `Promise<void>`. The runtime ignores return values.
|
||||
|
||||
## Local Testing
|
||||
|
||||
Use the local `wrangler dev` server to simulate cron triggers:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In another terminal, send a simulated cron request:
|
||||
|
||||
```bash
|
||||
# Trigger the 5 PM daily cron
|
||||
curl "http://localhost:8787/__scheduled?cron=0+17+*+*+*"
|
||||
|
||||
# URL-encode the cron string (spaces → +)
|
||||
```
|
||||
|
||||
The Worker responds with `200` and logs handler output to the dev server console.
|
||||
|
||||
### Simulating Multiple Crons
|
||||
|
||||
If you have several crons with different schedules, test each by passing the exact schedule string:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # every 5 min
|
||||
curl "http://localhost:8787/__scheduled?cron=0+0+1+*+*" # monthly
|
||||
```
|
||||
|
||||
## Worked Example: Trade Retention
|
||||
|
||||
The trading module uses a daily cron at `0 17 * * *` (5 PM UTC) to trim old trades:
|
||||
|
||||
**Module declaration (src/modules/trading/index.js):**
|
||||
|
||||
```js
|
||||
crons: [
|
||||
{
|
||||
schedule: "0 17 * * *",
|
||||
name: "trim-trades",
|
||||
handler: (event, ctx) => trimTradesHandler(event, ctx),
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
**wrangler.toml:**
|
||||
|
||||
```toml
|
||||
[triggers]
|
||||
crons = ["0 17 * * *"]
|
||||
```
|
||||
|
||||
**Handler (src/modules/trading/retention.js):**
|
||||
|
||||
```js
|
||||
/**
|
||||
* Delete trades older than 90 days.
|
||||
*/
|
||||
export async function trimTradesHandler(event, { sql }) {
|
||||
if (!sql) return; // database not configured
|
||||
|
||||
const ninetyDaysAgoMs = Date.now() - 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const result = await sql.run(
|
||||
"DELETE FROM trading_trades WHERE ts < ?",
|
||||
ninetyDaysAgoMs
|
||||
);
|
||||
|
||||
console.log(`[cron] trim-trades: deleted ${result.changes} old trades`);
|
||||
}
|
||||
```
|
||||
|
||||
**wrangler.toml:**
|
||||
|
||||
```toml
|
||||
[triggers]
|
||||
crons = ["0 17 * * *"]
|
||||
```
|
||||
|
||||
At 5 PM UTC every day, Cloudflare fires the `0 17 * * *` cron. The Worker loads the registry, finds the trading module's handler, executes `trimTradesHandler`, and logs the number of deleted rows.
|
||||
|
||||
## Worked Example: Stats Recalculation
|
||||
|
||||
Imagine a leaderboard module that caches top-10 stats:
|
||||
|
||||
```js
|
||||
export default {
|
||||
name: "leaderboard",
|
||||
init: async ({ db, sql }) => {
|
||||
// ...
|
||||
},
|
||||
crons: [
|
||||
{
|
||||
schedule: "0 12 * * *", // noon UTC daily
|
||||
name: "refresh-stats",
|
||||
handler: async (event, { sql, db }) => {
|
||||
if (!sql) return;
|
||||
|
||||
// Recompute aggregate stats from raw data
|
||||
const topTen = await sql.all(
|
||||
`SELECT user_id, SUM(score) as total_score
|
||||
FROM leaderboard_plays
|
||||
GROUP BY user_id
|
||||
ORDER BY total_score DESC
|
||||
LIMIT 10`
|
||||
);
|
||||
|
||||
// Cache in KV for fast /leaderboard command response
|
||||
await db.putJSON("cached_top_10", topTen);
|
||||
console.log(`[cron] refresh-stats: updated top 10`);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Every day at noon, the leaderboard updates its cached stats without waiting for a user request.
|
||||
|
||||
## Crons and Cold Starts
|
||||
|
||||
Crons execute on a fresh Worker instance (potential cold start). Module `init` hooks run before the first handler, so cron handlers can safely assume initialization is complete.
|
||||
|
||||
If `init` throws, the cron fires anyway but has `sql` and `db` in a half-initialized state. Handle this gracefully:
|
||||
|
||||
```js
|
||||
handler: async (event, { sql, db }) => {
|
||||
if (!sql) {
|
||||
console.warn("sql store not available, skipping");
|
||||
return;
|
||||
}
|
||||
// proceed with confidence
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a New Cron
|
||||
|
||||
1. **Declare in module:**
|
||||
```js
|
||||
crons: [
|
||||
{ schedule: "0 3 * * *", name: "my-cron", handler: myHandler }
|
||||
],
|
||||
```
|
||||
|
||||
2. **Add to wrangler.toml:**
|
||||
```toml
|
||||
[triggers]
|
||||
crons = ["0 3 * * *", "0 17 * * *"] # keep existing schedules
|
||||
```
|
||||
|
||||
3. **Deploy:**
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
4. **Test locally:**
|
||||
```bash
|
||||
npm run dev
|
||||
# in another terminal:
|
||||
curl "http://localhost:8787/__scheduled?cron=0+3+*+*+*"
|
||||
```
|
||||
|
||||
## Monitoring Crons
|
||||
|
||||
Cron execution is logged to the Cloudflare Workers console. Check the tail:
|
||||
|
||||
```bash
|
||||
npx wrangler tail
|
||||
```
|
||||
|
||||
Look for `[cron]` prefixed log lines to see which crons ran and what they did.
|
||||
Reference in New Issue
Block a user