Scheduling
Delay a task to run once in the future, or register it on a recurring cron schedule.
Delay a task to run once in the future, or register it on a recurring cron schedule.
flexiq supports two ways to schedule work ahead of time: delayed tasks (run once, at a specific point in the future) and periodic tasks (run repeatedly on a cron expression). Both ride the same scheduler that dispatches ordinary jobs — a delayed job just isn't eligible until its scheduled time passes, and periodic tasks are enqueued by the scheduler's maintenance loop when they come due. No separate cron daemon or scheduler process is needed.
Enqueue with a delay to run a task once, in the future:
# Run 1 hour from now
send_email.apply_async(
args=("user@example.com", "Reminder", "Don't forget!"),
delay=3600, # seconds
)// Run 1 hour from now
queue.enqueue("send_email", ["user@example.com", "Reminder", "Don't forget!"], {
delayMs: 60 * 60 * 1000,
});// Run 1 hour from now
flexiq.enqueue(sendEmail, payload, EnqueueOptions.builder()
.delay(Duration.ofHours(1))
.build());// Run 1 hour from now
queue.enqueue(
send_email::call(
"user@example.com".into(),
"Reminder".into(),
"Don't forget!".into(),
)
.delay_ms(60 * 60 * 1000),
)?;delay_ms is relative and resolved once at enqueue, against the same now as
expires_in_ms, so the two cannot disagree by the width of the call.
The job is created immediately — it just sits pending until the delay
elapses. A worker never claims it before then.
See Tasks for the rest of what
apply_async accepts alongside delay.
See Enqueue
options for the rest of what delayMs composes with.
See Enqueue
options for the rest of what delay composes with.
See Enqueue options for the
rest of what .delay_ms(..) composes with. Every setter on a TaskCall is
emitted from the same macro as the one on EnqueueOptions, so the two lists
cannot drift apart.
Register a task on a cron expression once; from then on, every running worker's scheduler enqueues a fresh job for it each time the schedule fires:
@queue.periodic(
name="daily-digest",
cron="0 0 9 * * *",
args=("2026-06-16",),
timezone="America/New_York",
)
def digest(date: str):
"""Run every day at 9:00 AM Eastern."""
send_digest(date)queue.task("digest", (date: string) => sendDigest(date));
// cron is 6/7-field, seconds first: sec min hour day-of-month month day-of-week
queue.registerPeriodic("daily-digest", "digest", "0 0 9 * * *", {
args: ["2026-06-16"],
timezone: "America/New_York",
});long nextFire = flexiq.registerPeriodic(
PeriodicTask.builder("daily-digest", "digest", "0 0 9 * * *")
.payload(Map.of("edition", "morning"))
.queue("emails")
.timezone("America/New_York")
.build());A schedule is an attribute on the task itself, and a scheduled task takes no parameters — a periodic fire has no caller to supply them, so a parameter here is a compile error:
/// Run every day at 9:00 AM Eastern.
#[flexiq::task(name = "daily-digest", cron = "0 0 9 * * *", timezone = "America/New_York")]
fn digest() -> flexiq::Outcome<()> {
send_digest()?;
Ok(())
}
// The schedule is written when the worker starts, not when the task is declared.
let worker = queue.worker().register::<digest>().spawn()?;A restart writes only what the declaration itself carries, so a worker coming back up never resumes a task an operator paused and never resets a deadline the scheduler advanced. The one exception is a declaration whose cron expression or timezone changed: that does replace the deadline, because the stored one was computed from a schedule that no longer exists.
Firing is per-scheduler and not leader-elected. The dedup key a fired job
carries is periodic:{name}:{now} computed from each process's own clock, so
two workers that both registered the same schedule can both find it due in the
same millisecond and each mint a job the other's key does not match. Register
periodics from one process, or accept that a fleet may double-fire.
@queue.periodic() registers the function as a normal task (it can still be
enqueued manually) and records the schedule. The schedule itself isn't sent
to the scheduler until run_worker()
starts — every @queue.periodic()-decorated function is collected and
registered as a batch at that point, upserted by name, so restarting a
worker with the same decorators just re-applies the same schedule rather
than duplicating it.
registerPeriodic(name, taskName, cron, options?) talks to the scheduler
immediately — there's no separate worker-startup step — and returns the
computed next fire time (Unix ms). Re-registering the same name replaces
the schedule (upsert by name).
registerPeriodic(PeriodicTask) talks to the scheduler immediately and
returns the next fire time (Unix ms). Re-registering the same name replaces
the schedule (upsert by name).
There is no separate registration call. The schedule rides on the task, so
register::<digest>() picks it up and spawn() writes the row — before the
scheduler starts, so a schedule that is already due fires on the first tick
rather than an interval late.
That write is conditional, and the condition is in the database rather than
in the worker. What the declaration carries is written; enabled is not part of
it and is never written, and next_run is written only when the cron expression
or the timezone changed. So a restart cannot resume a task an operator paused,
whatever the declaration says, and cannot reset a next_run another worker's
scheduler advanced unless the schedule it was computed from is gone.
A namespaced worker writes its schedules into its own namespace, so two tenants
can each declare a task called nightly without either one overwriting the
other's row.
Several schedules can share one @queue.periodic()-decorated worker file:
@queue.periodic(cron="0 */5 * * * *")
def health_check():
"""Run every 5 minutes."""
ping_services()
@queue.periodic(cron="0 0 0 * * *")
def daily_cleanup():
"""Run at midnight every day."""
queue.purge_completed(older_than=86400)
@queue.periodic(cron="0 0 9 * * 1", args=("weekly",))
def weekly_report(report_type):
"""Run every Monday at 9:00 AM."""
generate_report(report_type)Several schedules are several tasks, each carrying its own, registered on one worker:
#[flexiq::task(cron = "0 */5 * * * *")] // every 5 minutes
fn health_check() -> flexiq::Outcome<()> {
ping_services().map_err(flexiq::TaskError::retryable)?;
Ok(())
}
#[flexiq::task(cron = "0 0 0 * * *")] // midnight, every day
fn daily_cleanup() -> flexiq::Outcome<()> {
Ok(())
}
let worker = queue
.worker()
.register::<health_check>()
.register::<daily_cleanup>()
.spawn()?;A scheduled task takes no parameters, so a schedule cannot carry arguments — and
a parameter beside cron is a compile error rather than a fire that decodes
nothing, forever. Put the constant in the body, or leave the cron off and
enqueue the task by hand when a run genuinely needs an argument.
flexiq uses 6-field cron expressions (with seconds) — a 7th, optional year field is also accepted:
┌─────────── second (0-59)
│ ┌───────── minute (0-59)
│ │ ┌─────── hour (0-23)
│ │ │ ┌───── day of month (1-31)
│ │ │ │ ┌─── month (1-12)
│ │ │ │ │ ┌─ day of week (0-6, Sun=0)
│ │ │ │ │ │
* * * * * *
The cron field order is seconds first (sec min hour dom mon dow).
"0 0 9 * * *" is 09:00:00 daily, not every minute.
| Expression | Schedule |
|---|---|
*/30 * * * * * | Every 30 seconds |
0 */5 * * * * | Every 5 minutes |
0 0 * * * * | Every hour |
0 30 * * * * | Every hour at :30 |
0 0 */2 * * * | Every 2 hours |
0 0 0 * * * | Every day at midnight |
0 0 9 * * * | Every day at 9:00 AM |
0 0 9 * * 1-5 | Weekdays at 9:00 AM |
0 30 9 * * 1-5 | Weekdays at 9:30 AM |
0 0 0 1 * * | First day of every month at midnight |
0 0 0 * * 0 | Every Sunday at midnight |
0 0 0 1 1 * | January 1st at midnight (yearly) |
Coming from Celery: Celery's
crontab()only maps to the last 5 fields (minute, hour, day of month, month, day of week) — prepend a seconds field to get flexiq's 6-field format.
Coming from BullMQ? BullMQ attaches a repeat schedule to a job via
add(name, data, { repeat: { pattern: cron } })— the schedule lives inside an enqueue call. flexiq separates the two:registerPeriodic(name, taskName, cron, options?)is its own call, independent of any singleenqueue(), anddeletePeriodic/pausePeriodic/resumePeriodicmanage it by name afterward.
cron is the one attribute whose grammar the macro does not check. Durations and
rates are parsed while it expands, but the expression is passed through as a
string and read at spawn() — so a malformed one fails the worker's startup with
an error naming it, rather than at compile time.
@queue.periodic(
cron="0 0 * * * *", # Required: cron expression
name="hourly-cleanup", # Optional: explicit name
args=(3600,), # Optional: positional args
kwargs={"force": True}, # Optional: keyword args
queue="maintenance", # Optional: target queue
timezone="America/New_York", # Optional: IANA timezone (default: UTC)
)
def cleanup(older_than, force=False):
...queue.registerPeriodic("hourly-cleanup", "cleanup", "0 0 * * * *", {
args: [3600],
queue: "maintenance",
timezone: "America/New_York",
enabled: true, // register active immediately (default)
});flexiq.registerPeriodic(
PeriodicTask.builder("hourly-cleanup", "cleanup", "0 0 * * * *")
.payload(Map.of("olderThan", 3600))
.queue("maintenance")
.timezone("America/New_York")
.enabled(true) // register active immediately (default)
.build());#[flexiq::task(
cron = "0 0 * * * *", // Required: 6-field cron, seconds first
name = "hourly-cleanup", // Optional: the schedule's name is the task's
queue = "maintenance", // Optional: queue each fired job lands on
timezone = "America/New_York", // Optional: IANA name (default: UTC)
)]
fn cleanup() -> flexiq::Outcome<()> {
Ok(())
}| Attribute | Effect |
|---|---|
cron | The schedule. Six fields, seconds first. |
name | The task name, which is also the schedule's name. Defaults to the function's name. |
queue | The queue a fired job is enqueued into. Defaults to "default". |
timezone | IANA timezone the expression is evaluated in. Only meaningful beside cron. |
There is no args — a scheduled task takes no parameters — and no enabled: a
schedule registers active, and pausing one is
queue.pause_periodic("hourly-cleanup").
| Parameter | Type | Default | Description |
|---|---|---|---|
cron | str | required | Cron expression (6-field, seconds first). |
name | str | None | the function's module.qualname | Explicit schedule name. Re-registering the same name replaces it. |
args | tuple | () | Positional arguments passed to the task on each run. |
kwargs | dict | None | None | Keyword arguments passed to the task on each run. |
queue | str | "default" | Named queue the enqueued job targets. |
timezone | str | None | None (UTC) | IANA timezone the cron expression is evaluated in. |
| Option | Type | Default | Description |
|---|---|---|---|
args | unknown[] | [] | Positional args passed to the task each time it fires. |
queue | string | "default" | Queue the periodic job is enqueued into. |
timezone | string | UTC | IANA timezone the cron expression is evaluated in. |
enabled | boolean | true | Register active immediately, or paused (won't fire until resumed). |
| Builder method | Description |
|---|---|
payload(Object) | Payload passed to the task on each fire. |
queue(String) | Queue to enqueue into. |
timezone(String) | IANA timezone for the cron expression (default UTC). |
enabled(boolean) | Register paused when false (default true). |
Of the enqueue-default attributes, only queue reaches the job a firing creates.
The scheduler builds that job itself, from fixed values, so priority,
max_retries, timeout, expires and result_ttl shape a call you enqueue by
hand and nothing the schedule mints:
| On a fired job | Value |
|---|---|
queue | The task's declared queue, or "default". |
priority | 0 |
max_retries | 3 |
timeout_ms | 300_000 |
unique_key | periodic:{name}:{now}, from the firing process's own clock. |
The scheduler-policy half is unaffected, because it is keyed by task name rather
than carried on the row: rate_limit, retry_budget, max_concurrent,
on_excess, and the retry_backoff_ms / retry_max_delay_ms curve all apply to
a fired job exactly as they do to any other.
Cron expressions are evaluated in UTC by default. Pass any IANA timezone
name to schedule in local time instead — daylight saving transitions are
handled automatically (via chrono-tz under the hood), so a schedule pinned
to "09:00 America/New_York" keeps firing at 9 AM local time across the
spring/fall clock changes.
queue.listPeriodic(); // every registered schedule, enabled or paused
queue.pausePeriodic("daily-digest"); // stop firing, keep the registration
queue.resumePeriodic("daily-digest");
queue.deletePeriodic("daily-digest"); // unschedule; false if unknownList<PeriodicInfo> all = flexiq.listPeriodic(); // enabled and paused
flexiq.pausePeriodic("daily-digest"); // stop firing, keep the registration
flexiq.resumePeriodic("daily-digest");
flexiq.deletePeriodic("daily-digest"); // unschedule; false if unknownqueue.list_periodic() # every registered schedule, enabled or paused
queue.pause_periodic("daily-digest") # stop firing, keep the registration
queue.resume_periodic("daily-digest")
queue.delete_periodic("daily-digest") # unschedule; False if unknownEach entry is a PeriodicInfo — name, task_name, cron_expr, queue,
enabled, last_run, next_run, timezone. Timestamps are Unix
milliseconds, and last_run is None until the schedule first fires:
for schedule in queue.list_periodic():
state = "on" if schedule.enabled else "paused"
print(f"{schedule.name} [{state}] next={schedule.next_run}")Every method also has an a-prefixed async form — alist_periodic,
apause_periodic, aresume_periodic, adelete_periodic.
These act on the catalog in storage, so any process pointed at the same backend
can read and manage it — including a producer that never starts a worker. What
such a process won't see are the schedules it declares: @queue.periodic()
only declares one, and writing it to storage is something
run_worker() does at startup.
For the same reason, removing a @queue.periodic() decorator does not
delete its row: the schedule keeps firing and enqueuing jobs for a task name no
running worker handles anymore. Retire one with delete_periodic() rather than
by deleting the decorator.
queue.list_periodic()?; // every registered schedule, enabled or paused
queue.pause_periodic("daily-digest")?; // stop firing, keep the registration
queue.resume_periodic("daily-digest")?;
queue.delete_periodic("daily-digest")?; // unschedule; false if there was noneEach entry is a flexiq::PeriodicTask — name, task_name, cron_expr,
queue, enabled, last_run, next_run, timezone. Timestamps are Unix
milliseconds and last_run is None until the schedule first fires:
for schedule in queue.list_periodic()? {
let state = if schedule.enabled { "on" } else { "paused" };
println!("{} [{}] next={}", schedule.name, state, schedule.next_run);
}All four act on the catalog in storage, so any process pointed at the same backend can manage it — including a producer that never starts a worker.
Deleting the row is not the same as retiring the schedule. A worker that still
has the task in its register::<T>() chain writes it back at its next startup,
because the declaration is in the code and the row is only its record. Remove the
cron attribute as well, or the schedule comes back with the next deploy.
A schedule is identified by (namespace, name), so a name is unique within
a namespace and nowhere else. Every call above rides the handle's namespace:
tenants sharing one database each register, list, pause and delete their own
schedules, and deleting a name only another tenant holds reports "not found"
rather than reaching it. A handle with no namespace addresses the default
namespace — one namespace, not every tenant's.
The scheduler reads the other way, because it is not addressing a schedule: one running without a namespace fires every namespace's due schedules, and each job it enqueues lands in the schedule's own namespace. Run a scheduler per tenant to narrow that.
A slow run can overlap its own next fire. The scheduler only checks whether a schedule's next-run time has passed — it doesn't track whether the previous fire is still executing. If a periodic task's execution regularly outlasts its own cron interval, a new job can be enqueued and dispatched before the last one finishes.
If overlap is unsafe for a task, cap it with max_concurrent=1 on the
underlying task (see
per-task
concurrency) — the poller enforces that limit by task name, whether
the job came from a periodic schedule or a normal enqueue.
If overlap is unsafe for a task, cap it with maxConcurrent: 1 (see
concurrency) — the
scheduler enforces that limit by task name, whether the job came from a
periodic schedule or a normal enqueue.
The Java SDK doesn't expose a per-task concurrency cap in code yet. If overlapping runs would be unsafe, keep the cron interval comfortably longer than the task's worst-case execution time, or make the handler itself safe to run concurrently with itself.
If overlap is unsafe, cap the task at one. max_concurrent is enforced by the
scheduler by task name, so it holds across every worker on shared storage and
does not care whether the job came from a schedule or a hand-written enqueue:
#[flexiq::task(cron = "0 */5 * * * *", max_concurrent = 1)]
fn slow_sync() -> flexiq::Outcome<()> {
Ok(())
}A fire that finds the cap full is never dropped: a concurrency cap rolls the
claim back and the job stays pending for a later tick. on_excess has no say
here — it answers only for a rate limit.
Missed windows aren't backfilled. If no worker's scheduler has been running — say, during a deploy — due schedules just accumulate. When a scheduler comes back, it enqueues only the single next due occurrence, not one job per missed interval.
Multiple workers sharing one schedule is safe in practice. Every running
worker's scheduler checks for due periodic tasks independently — there's no
leader election. Each fire's job carries a unique_key derived from the
schedule name and the exact fire timestamp, enforced by a partial unique
index on active jobs, so two schedulers racing the same due window can't
both insert a job for it.
Periodic task state — the schedule, its cron expression, and its next/last
fire time — is persisted in storage (the periodic_tasks table for
SQLite/Postgres, or periodic:* keys for Redis), so it survives worker
restarts.