Scheduled Jobs (Cron)
Cron đang chạy
Bảng này là toàn bộ triggers.crons của mọi wrangler.jsonc trong repo; thêm cron mà không thêm dòng ở đây là vi phạm luật 5 bên dưới. (Audit #013 T-3 bắt được trang này khai 1 cron khi worker chạy 4, và cổng freshness không thấy vì chỉ đo ngày commit của chính trang.)
| Worker | Cron (UTC) | Giờ VN | Job | Khoá / trần | Nguồn |
|---|---|---|---|---|---|
nemo12-api | */5 * * * * | mỗi 5 phút | Nhịp AI: xưởng curriculum (runCurriculumAuthoring) → xưởng IELTS (runIeltsAuthoring) → dịch khung b21 (runB21Translation). Một ngân sách lượt gọi model chung cho cả nhịp (shared/aiBudget.ts) | withLock("b21-translate", 240 s); trần 30 lượt/nhịp, chưa có trần USD/ngày (Audit #013 P-1) | SRC-642, SRC-634 |
nemo12-api | 10 * * * * | phút 10 mỗi giờ | Nâng candidate sạch lên published (promoteScreenedCandidates, tối đa 4 lô × 500) | withLock("promote-candidates", 3000 s) | SRC-538 |
nemo12-api | 0 19 * * * | 02:00 | Thư tổng kết hằng ngày cho phụ huynh (runDailyDigest, WF digest) | withLock("parent-digest", 6 h); chống gửi trùng bằng PK (learner_id, digest_date) | SRC-452, SRC-529 |
nemo12-api | 0 19 * * * (thứ Hai) | 02:00 | Báo cáo tuần cho phụ huynh (runWeeklyReports) — chạy kèm nhịp trên, chỉ thứ Hai giờ VN | withLock("parent-weekly", 6 h); dedupe learner:email:tuần | SRC-683 |
nemo12-api | 0 12 * * * | 19:00 | Lượt quét thư trạng thái (runParentMailPass): nhắc dựng hồ sơ · mốc kỹ năng · nghỉ dài · đếm ngược thi · nhắc buổi học thử ngày mai | withLock("parent-mail-pass", 6 h); mỗi loại thư try/catch riêng; chống trùng bằng email_dispatches | SRC-683 |
nemo12-api | 0 12 * * * | 19:00 | Lượt quét thư cho IELTS Learner (runIeltsLearnerPass, SRC-737): 8 loại thư gửi thẳng cho người học — xác nhận mục tiêu · xác nhận điểm xuất phát · xong cụm hồ sơ · lộ trình sẵn sàng · qua mốc · kỹ năng lên nửa band · đếm ngược · im lặng đã lâu. Chạy SAU lượt của phụ huynh trong cùng nhánh cron | withLock("ielts-learner-mail", 6 h); mỗi loại try/catch riêng; trần nhóm 2 thư/7 ngày ngoài trần chung 3 thư/ngày | SRC-737 |
nemo12-api | 0 21 * * * | 04:00 | WF-17 Retention Refresh (runRetentionRefresh, ≤200 learner/lượt) | withLock("retention-refresh", 6 h) | SRC-104 |
nemo12-foundry | */15 * * * * | mỗi 15 phút | Kéo việc xưởng nội dung theo content_priority (WF-19/WF-20); tắt bằng FOUNDRY_AUTO_DRAIN ≠ "1" | 5 chốt của startRun + FOUNDRY_DAILY_USD_CAP | SRC-632, SRC-636 |
Khai báo: "triggers": { "crons": ["0 21 * * *", "0 19 * * *", "0 12 * * *", "10 * * * *", "*/5 * * * *"] } trong workers/api/wrangler.jsonc; ["*/15 * * * *"] trong workers/foundry/wrangler.jsonc. Tất cả đi qua scheduled() của worker tương ứng và rẽ nhánh theo controller.cron.
⏰ Cron của Cloudflare chạy theo UTC. Việt Nam là UTC+7 —
0 21 * * *là 04:00 ICT, đúng giờ ít người dùng nhất.
Chi phí đáng nhớ: nhịp */5 là job duy nhất tự gọi model mà không ai bấm nút (288 nhịp/ngày); tháng 8/2026 nó là phần lớn của hoá đơn Workers AI, và các câu SELECT dò việc của nó quét cả bảng là phần lớn của hoá đơn D1 (hoá đơn IN-77746678). Thêm việc vào nhịp này thì phải trả lời trước: hết việc thì nhịp rỗng tốn bao nhiêu dòng đọc, và target hỏng có bị chọn lại mãi không (P-2).
WF-17 — Retention Refresh
Vì sao phải có cron cho việc này
Đây là job duy nhất trong hệ thống bắt buộc phải chạy khi không ai online, vì lý do bản chất:
Trí nhớ phai theo THỜI GIAN, không theo HÀNH ĐỘNG.
Mọi model khác chỉ đổi khi có việc xảy ra — học sinh nộp bài, phụ huynh khai mục tiêu. Retention thì ngược lại: chính lúc học sinh không làm gì mới là lúc trí nhớ phai đi. Không có cron thì học sinh nghỉ ba tuần sẽ không có bất kỳ tín hiệu nào cho biết em ấy đang quên dần.
Chạy gì
runRetentionRefresh(env, { trigger: "cron:0 21 * * *" })
├── startWorkflowRun(code: "WF-17")
├── chọn tối đa 200 learner (RETENTION_REFRESH_LIMIT)
│ ORDER BY last_snapshot ASC — learner CHƯA từng có snapshot (NULL) lên đầu
├── mỗi learner → runRetentionEngine()
│ ├── refreshRetentionProjections() ← chỉ trường phái sinh
│ └── saveModelVersion(model_kind: "retention")
│ → mỗi ngày có snapshot "hôm đó trí nhớ em thế nào"
└── finishWorkflowRun()Mỗi learner là một step riêng trong run (refresh:{learnerId}), nên một learner lỗi không làm hỏng cả lượt.
Giới hạn 200 learner/lượt
Không quét toàn bộ. Learner vượt cap sẽ tự lên đầu hàng đợi hôm sau vì thứ tự là last_snapshot ASC — cơ chế công bằng tự cân bằng, không cần bảng hàng đợi.
Ở quy mô 10.000 learner, cap này cần nâng hoặc chia lượt — theo dõi deferred trong output của run.
An toàn khi chạy trùng
refreshRetentionProjections() chỉ ghi trường phái sinh (review_urgency, next_review_*, retention_confidence), không bao giờ đụng anchor (current_retention, stability_days, last_exposure_at).
Vì hàm mũ là memoryless, re-anchor tại now với giá trị đã decay cho ra đúng cùng các mốc tuyệt đối. Chạy 1 lần hay 100 lần trong ngày đều ra cùng một lịch ôn. Bất biến này được khoá bằng test modules/retention/periodic.test.ts.
Chi tiết: retention-model §5.
Chạy tay
POST /v1/admin/retention-refresh (role admin) — cùng workflow WF-17, dùng khi cần kiểm chứng ngay thay vì đợi tới 4 giờ sáng.
Quan sát
| Tín hiệu | Ở đâu |
|---|---|
RETENTION_REFRESH_DONE + { processed, deferred, changed } | Workers logs |
RETENTION_REFRESH_FAILED | Workers logs |
| Run + từng step | GET /v1/admin/workflow-runs (lọc WF-17) |
Việc chưa có cron
| # | Job | Lịch đề xuất | Vì sao cần | Chặn bởi |
|---|---|---|---|---|
| 1 | Nhắc lịch thi — còn ≤14 / ≤3 ngày | Hằng ngày | Goal operational chỉ đổi hành vi khi có người mở app; deadline không tự nhắc | cần hệ thống notification |
| 2 | Dọn session hết hạn | Hằng ngày | sessions chỉ lọc expires_at lúc đọc — bảng phình vô hạn | không |
| 3 | Cảnh báo DLQ | Mỗi 15 phút | Message chết im lặng (queues §4) | QG-009 |
| 4 | Freshness tài liệu/nội dung | Tuần | QG-001/QG-006 đang kiểm thủ công | không |
Luật khi thêm cron
- Idempotent. Cron sẽ chạy trùng (retry, deploy chồng). Hai lần phải cho cùng kết quả — WF-17 đạt được bằng tính memoryless, không phải bằng khoá.
- Có giới hạn. Không job nào quét toàn bộ learner không cap. Có cap thì phải có cơ chế cho phần bị hoãn được ưu tiên lượt sau.
- Ghi run log qua
startWorkflowRun()vớitrigger: "cron:…". Job đêm không có log là job không ai biết đã hỏng. - Bọc
ctx.waitUntil()+ try/catch — cron nổ không được làm chết worker. - Khai vào bảng trên trong cùng PR. Không có cron ẩn (REQ-DOC-02). Bảng trên phải khớp
triggers.cronscủa mọiwrangler.jsonc— kiểm bằnggrep -h '"crons"' workers/*/wrangler.jsonc. - Nói rõ nhịp rỗng tốn gì. Cron chạy mãi kể cả khi hết việc; câu "hết việc thì không tốn gì" chỉ đúng khi câu SELECT dò việc đi qua chỉ mục. Ghi ước lượng dòng đọc/nhịp ngay trong comment handler.
// workers/api/src/index.ts
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
ctx.waitUntil((async () => {
try { /* dispatch theo controller.cron */ }
catch (e) { console.error("…_FAILED", e); }
})());
}Trace
- REQ-INT-30 (retention refresh định kỳ — SRC-104), REQ-PAR-11/12 (digest phụ huynh — SRC-452/529), SRC-538 (nâng candidate), SRC-642 (dịch b21), SRC-632/636 (xưởng nội dung), SRC-674 (Audit #013 T-3).
- Thiết kế: SDD-017 §15, SDD-006.
- Kiểm chứng: QG-005 (
periodic.test.ts), QG-009.
Thư gửi từ các cron này mô tả ở Email.