Events, Queues, Scheduling & Commands
Events & Listeners
In-process, synchronous pub/sub - no persistence, no queue involved
unless a listener explicitly dispatches one. Any Clone + Send + Sync +
'static type is automatically an Event - there’s no trait to
implement:
#[derive(Clone)]
pub struct PostCreated {
pub post_id: i64,
pub title: String,
pub user_id: i64,
}
Register a listener once, at boot (before serve()):
larust_support::event::listeners()
.on::<PostCreated, _, _>(|event: PostCreated| async move {
larust_support::tracing::info!(post_id = event.post_id, "post created");
});
Dispatch it from wherever the thing actually happened:
larust_support::event::dispatch(PostCreated {
post_id: post.id, title: post.title.clone(), user_id: post.user_id,
}).await;
Every registered listener for that event type runs; there’s no return value and no way for a listener to cancel the event or affect the dispatcher’s own return.
Queues
Durable, persisted jobs - dispatching survives even if nothing is
currently running to pick the job up. A Job is a serializable struct
with a stable type tag and a handle() method:
#[derive(Serialize, Deserialize)]
pub struct NotifyPostCreatedJob {
pub post_id: i64,
}
impl Job for NotifyPostCreatedJob {
const JOB_TYPE: &'static str = "notify_post_created";
async fn handle(&self) -> Result<(), AppError> {
larust_support::tracing::info!(post_id = self.post_id, "notifying");
Ok(())
}
}
larust_support::queue::dispatch(&NotifyPostCreatedJob { post_id: post.id }).await?;
JOB_TYPE is a stable, hand-chosen string - deliberately not
std::any::type_name::<Self>(), which isn’t stable across a
rename/refactor. A row already sitting in the jobs table under the old
type name would silently stop matching any handler if this were left to
reflection.
# .env
QUEUE_DRIVER=database # default - the same connection DB_CONNECTION points at
# QUEUE_DRIVER=redis # REDIS_URL=redis://127.0.0.1:6379
Running workers
xr queue:work
Claims and processes jobs until stopped. On the database driver, a job
is claimed via an atomic DELETE ... RETURNING-shaped race-safe write -
two worker processes racing for the same job, one wins, the other moves
on to the next. A failing job retries with exponential backoff up to a
fixed attempt count, then lands in a failed_jobs table rather than
disappearing silently.
Registering job types
main.rs’s queue:work branch builds a JobRegistry naming every job
type your app dispatches:
let registry = larust_support::queue::JobRegistry::new()
.register::<larust_support::mail::MailJob>() // built in - powers Mail's .queue()
.register::<crate::jobs::NotifyPostCreatedJob>();
larust_support::queue::work(registry).await
A job type dispatched but never registered here fails loudly (a real, by-design gap, not a silent no-op) - the worker has no way to deserialize a payload it doesn’t know the shape of.
Task Scheduling
Schedule is Laravel’s own scheduling vocabulary - routes/console.rs
declares tasks, xr schedule:work runs whatever’s due, once a second:
pub fn schedule() -> Schedule {
Schedule::new()
.daily(|| async {
let count = Post::query().count().await?;
larust_support::tracing::info!(count, "daily post count");
Ok(())
})
.hourly(|| async { cleanup_expired_sessions().await })
.cron("0 */15 * * * *", || async { poll_external_api().await })
}
.every_minute(...)/.hourly(...)/.daily(...)/.daily_at("13:00",
...)/.weekly(...)/.monthly(...)/.cron(expr, ...) - a task is a
plain closure, run in the same process that declared it (unlike a Job,
there’s no serialization boundary, so it can close over anything).
.cron(...) uses the cron crate’s own 7-field dialect (seconds,
minutes, hours, day-of-month, month, day-of-week, year) - not Laravel’s
classic 5-field Unix cron. An easy mismatch to assume away if you’re
translating a Laravel schedule directly.
xr schedule:work
Not safe to run as more than one process against the same app by
default - there’s no coordination step at all, so two schedule:work
processes both run every due task, silently duplicating side effects.
Opt a specific task into cross-process safety explicitly:
Schedule::new()
.name("send-weekly-digest") // required before .on_one_server()
.weekly(send_weekly_digest)
.on_one_server()
The first process to win a race-safe INSERT for a given (task name,
scheduled instant) runs it; every other process racing for the same pair
loses and skips it. .name(...) is required first - a closure has no
identity of its own to key that row on.
Named CLI Commands
Laravel’s Artisan::command('name', $closure), as a real trait rather
than a closure - larust-console’s Command:
use larust_support::console::Command;
use larust_support::AppError;
pub struct ReportPosts;
impl Command for ReportPosts {
const NAME: &'static str = "report:posts";
const DESCRIPTION: &'static str = "Prints the current post count";
async fn handle(_args: &[String]) -> Result<(), AppError> {
let count = Post::query().count().await?;
println!("{count} posts");
Ok(())
}
}
xr make:command ReportPosts # scaffolds app/Console/Commands/report_posts.rs
Register it in routes/console.rs, alongside schedule():
pub fn commands() -> CommandRegistry {
CommandRegistry::new().register::<crate::commands::ReportPosts>()
}
cargo run -- report:posts
# or, once xr wraps your compiled binary the way it does for every other
# subcommand:
xr report:posts
Command::handle is an associated function, not an instance method - a
command has no meaningful state of its own beyond the args it’s handed
(xr report:posts --format=json hands ["--format=json"]; parsing them
is the command’s own job, same as Job/Notification’s own payloads).
xr command:list prints every registered command’s name and description.
A typo’d or unregistered command name is a loud, immediate error, not a silent fall-through into starting the web server - unlike a job or a scheduled task, a command is something a person just typed, and getting it wrong deserves to be told so immediately.