.offrails/generators/job.md
Generator: job (rails generate job) and task
TL;DR
cargo offrails g job SendWeeklyDigest user_id:bigint [--queue mailers]writessrc/jobs/send_weekly_digest.rs(aWorkerwith id-only args,MAX_ATTEMPTS = 5), its arms indispatch,worker_configandWORKERS, andtests/app/send_weekly_digest_job_test.rs; it needs thejobspart (cargo offrails add jobs). Priority, uniqueness and cron (steps 1 and 7) are yours, in that file andsrc/jobs/recurring.rs.- Choose outcomes deliberately (
Done,Err→ retry,Snooze,Discard,Cancel); make jobs idempotent; enqueue from the model fn in the same transaction.- A new queue goes into
APP_QUEUES,.env.exampleand every worker role;--everyadds aRecurringentry.Read the rest when adding a job or a task (
app task <name>).
Job
cargo offrails g job SendWeeklyDigest user_id:bigint --queue mailers
# by hand after it: PRIORITY, unique(), a Recurring entry for "0 8 * * MON", the perform bodysrc/jobs/<verb>_<noun>.rs: a#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SendWeeklyDigestJob { pub user_id: i64 }withimpl Worker(NAME = "send_weekly_digest", plusQUEUE/PRIORITY/MAX_ATTEMPTS/TIMEOUTonly if they differ from the defaults, andfn unique() -> Option<Unique> { Some(Unique::args_keys(&["user_id"]).period(chrono::Duration::hours(24))) }for--unique user_id:24h). SizeMAX_ATTEMPTSto the work: the default 20 (~3 weeks) is for syncs and imports, not for anything time-limited. See guides/jobs.md.- Args = ids, not records. Load fresh data in
perform. If the record is gone, returnOk(Outcome::Cancel(...)). - Pick the outcome deliberately:
Donenormally;Err(via?) for transient failures you want retried;Snoozefor "not yet";Discardfor inputs that will never work. Add.context(...)to errors so the dashboard's error history explains them. - Idempotent: a job may run twice (crash after the side effect, before completion). Guard side effects (for example, record
sent_atand skip if it's set). - Register it in
dispatchandworker_configinsrc/jobs/mod.rs(pub mod+pub usetoo), and inWORKERS. The generator does this. - A new queue (
--queue digests) must be added to theAPP_QUEUESdefault inconfig.rs, to.env.example, and to every worker role in the deploy config. Otherwise its jobs wait forever (the dashboard shows "no nodes"). --every: addRecurring { key: "weekly_digest", cron: "0 8 * * MON", job: || FanOutWeeklyDigestJob.into_job() }toSCHEDULEinsrc/jobs/recurring.rs(unique key, UTC, croner syntax). The Cron plugin tags the jobcronand setsmeta.cron = key; don't add those yourself. A recurring job that fans out over many records is aFanOut…Jobthatkernel::jobs::insert_alls oneJobBuilderper record.- Enqueue from the owning model fn, in the same transaction as the data change. Add tags an operator would search for (
user:{id}). - Tests: trigger through the model fn and
drain_jobs(), then assert the effect. Test the record-gone path withperform_job(expectOutcome::Cancel). If the spec is about timing ("sent at 8am", "within an hour"), assert it withassert_enqueued::<W>(|w| …)/enqueued::<W>()(rows + decoded args). Neversleepin a test; runtime tests usewait_until.
Tasks
task backfill_slugssrc/tasks/backfill_slugs.rswithpub async fn run(state: &AppState, args: Vec<String>) -> anyhow::Result<()>.- Register it in the
matchinsrc/tasks/mod.rs. - Batch work with keyset pagination (1000 rows per batch). Log progress with
tracing::info!. Make it idempotent and safe to re-run. - Destructive tasks take
--dry-runby default and need--executeto write. - Run it locally against a seeded DB first, then in production via the deploy platform's one-liner (deployment).
- If it touches more than ~10k rows, or calls external services, make it a job fan-out instead, so progress and failures are visible in
/admin/jobs.