Tasks
Register task functions and attach per-task retry, timeout, and other execution options.
Register task functions and attach per-task retry, timeout, and other execution options.
A task is a named function bound to a queue. The name is the contract — producers enqueue by name, and any worker with that name registered executes the function and stores its return value as the job's result.
Declaring a task on the queue object means the task module needs the module that built the queue. To break that coupling — declare tasks without naming a queue and let one claim them later — see Task discovery. Everything on this page applies either way; only the declaration site moves.
from flexiq import Queue
queue = Queue(db_path="myapp.db")
@queue.task(queue="emails", max_retries=5)
def send_email(to: str, subject: str, body: str) -> str:
smtp.send(to, subject, body)
return "sent"import { Queue } from "@byteveda/flexiq";
const queue = new Queue({ dbPath: "myapp.db" });
// The name is the contract; the handler runs on a worker and its return is the result.
queue.task(
"sendEmail",
async (to: string, subject: string, body: string) => {
await smtp.send(to, subject, body);
return "sent";
},
{ maxRetries: 5 }, // queue is chosen per-enqueue, not here
);record EmailPayload(String to, String subject, String body) {}
// Producer side: a typed descriptor (name + payload type + defaults).
Task<EmailPayload> sendEmail = Task.of("send_email", EmailPayload.class)
.queue("emails")
.maxRetries(5);
// Worker side: bind the logic separately as a TaskFunction.
Worker worker = flexiq.worker()
.handle(sendEmail, payload -> { smtp.send(payload); return "sent"; })
.start();For generic payloads that a Class token can't express, use a Jackson
TypeReference instead: Task.of("send_email", new TypeReference<Map<String, Object>>() {}).
// The name is the function's, unless `name = "..."` says otherwise.
#[flexiq::task(queue = "emails", max_retries = 5)]
fn send_email(to: String, subject: String, body: String) -> flexiq::Outcome<String> {
smtp::send(&to, &subject, &body).map_err(flexiq::TaskError::retryable)?;
Ok("sent".to_string())
}
let queue = flexiq::FlexiQ::open("myapp.db")?;
// Producer side: `call(..)` mirrors the function's own argument list.
queue.enqueue(send_email::call(
"user@example.com".into(),
"Welcome".into(),
"…".into(),
))?;
// Worker side: registration is explicit, one `register::<T>()` per task.
let worker = queue.worker().queues(["emails"]).register::<send_email>().spawn()?;#[flexiq::task] replaces the function with a type of the same name carrying
three things: send_email::call(..) builds an enqueueable value,
send_email::run(..) is the original body and stays directly callable from a
unit test, and the Task implementation is what register::<send_email>()
takes.
A Task<T> is just a name and a payload type — it's a producer-side
descriptor, not a function. A worker binds the actual logic separately, as a
TaskFunction<T, R> that receives the deserialized payload and returns a
result (or null):
try (Worker worker = flexiq.worker()
.handle(sendEmail, payload -> deliver(payload)) // Task + TaskFunction
.handle("resize", ImageJob.class, payload -> resize(payload)) // name + payload type
.start()) { ... }Handler.of(task, fn) pairs the two for registration via register(handler)
or a HandlerRegistry. See Workers
for the full worker lifecycle.
@TaskHandlerAnnotate handler methods with @TaskHandler; the compile-time processor
(org.byteveda:flexiq-processor) generates a <Class>Tasks companion with a
typed Task constant per method plus a bind(...) — name declared once, full
generics, zero runtime reflection:
class EmailTasks {
@TaskHandler("send_email") // explicit name
String send(EmailPayload p) { ... }
@TaskHandler // name defaults to "report"
Report report(List<Metric> metrics) { ... }
}
// generated EmailTasksTasks:
String id = flexiq.enqueue(EmailTasksTasks.SEND, payload);
flexiq.worker()
.apply(b -> EmailTasksTasks.bind(b, new EmailTasks()))
.start();The annotation also accepts queue, maxRetries, timeoutMs, priority,
idempotent, and a circuitBreakerThreshold (plus
circuitBreakerWindowSeconds/circuitBreakerCooldownSeconds/
circuitBreakerHalfOpenProbes/circuitBreakerHalfOpenSuccessRate) for
per-task defaults. See
installation for the
processor setup.
Each companion also carries a nested Provider that the processor lists in
META-INF/services, so a worker can skip the bind(...) line entirely and call
.discover() instead — see
Task discovery.
Register a handler for every task a worker's queues can carry — a job whose task has no handler on the claiming worker fails with "no handler registered". Re-registering a task name replaces the previous handler.
There is no separate descriptor to bind. #[flexiq::task] generates the type
and keeps the body, and register::<send_email>() takes that type — so the
name, the argument types and the handler cannot drift apart:
let worker = queue
.worker()
.register::<send_email>()
.register::<resize>()
.spawn()?;flexiq::Task is a public trait — NAME, config(), defaults(),
run_encoded(), and an optional periodic() — so a task with an unusual shape
can implement it by hand and register exactly the same way. The macro is
ergonomics, not plumbing.
Register every task a worker's queues can carry. A job whose task is not
registered on the claiming worker fails fatally with task not registered on this worker — no amount of retrying makes it runnable there. Registering
one name twice is refused by spawn() rather than silently replacing the
first body, because one of the two would never run.
Attach retry, timeout, priority, and other execution defaults when you register the task. Each SDK exposes them with its own names and shape:
| Parameter | Type | Default | Description |
|---|---|---|---|
name | str | None | Auto-generated | Explicit task name. Defaults to module.qualname. |
max_retries | int | 3 | Max retry attempts before moving to DLQ. |
retry_backoff | float | 1.0 | Base delay in seconds for exponential backoff. |
retry_delays | list[float] | None | None | Per-attempt delays in seconds, overrides backoff. e.g. [1, 5, 30]. |
max_retry_delay | int | None | None | Cap on backoff delay in seconds (default 300 s). |
timeout | int | 300 | Max execution time in seconds (hard timeout). |
soft_timeout | float | None | None | Cooperative time limit; checked via current_job.check_timeout(). |
priority | int | 0 | Default priority (higher = more urgent). |
rate_limit | str | None | None | Rate limit string, e.g. "100/m". |
queue | str | "default" | Named queue to submit to. |
circuit_breaker | dict | None | None | Circuit breaker config: {"threshold": 5, "window": 60, "cooldown": 120}. |
middleware | list[TaskMiddleware] | None | None | Per-task middleware, applied in addition to queue-level middleware. |
inject | list[str] | None | None | Worker resource names to inject as keyword arguments. See Resource System. |
serializer | Serializer | None | None | Per-task serializer override. Falls back to the queue-level serializer. |
max_concurrent | int | None | None | Max concurrent running instances of this task. None means no limit. |
@queue.task(
name="emails.send",
max_retries=5,
retry_backoff=2.0,
max_retry_delay=60, # cap backoff at 60 s
timeout=60,
priority=10,
rate_limit="100/m",
queue="emails",
max_concurrent=10,
)
def send_email(to: str, subject: str, body: str):
...| Option | Description |
|---|---|
maxRetries | Attempts before dead-lettering. See retries. |
retryBackoff | Exponential backoff bounds (baseMs, maxMs). |
timeoutMs | Per-attempt timeout. |
maxConcurrent | Max simultaneously-running jobs of this task (concurrency). |
rateLimit | Rate limit string, count/unit (e.g. "100/m"). |
circuitBreaker | Trip after repeated failures (circuit breakers). |
queue.task("add", (a: number, b: number) => a + b, {
maxRetries: 3,
retryBackoff: { baseMs: 1000, maxMs: 60_000 },
timeoutMs: 30_000,
maxConcurrent: 4,
rateLimit: "100/m",
circuitBreaker: { threshold: 5, windowMs: 60_000, cooldownMs: 30_000 },
});Register a task before enqueuing or running a worker for it. Per-task config
is applied to the scheduler at registration time — re-registering replaces
it. There's no task-level default for queue or priority — set those per
job with enqueue options
instead.
Beyond per-task config, set defaults for an entire named queue:
queue.configureQueue("emails", { rateLimit: "50/s" });BullMQ splits a job into queue.add(name, data) on one side and a new Worker(queueName, processor) callback on the other — the processor lives
wherever you construct the Worker. flexiq ties the handler to the task
name once, via queue.task(name, handler, options); enqueue() just
references that name, and runWorker() takes no processor argument. Also
note: enqueue() returns the job id (a string), not a rich Job object —
fetch a snapshot with queue.getJob(id) or block on
queue.result(id).
Fluent methods attach default enqueue options; each returns a new descriptor (the type is immutable):
| Method | Description |
|---|---|
queue(name) | Route jobs to a named queue (queues). |
priority(n) | Higher dequeues first within a queue. |
retries(n) / maxRetries(n) | Attempts before dead-lettering. |
timeout(duration) / timeoutMs(ms) | Per-attempt timeout. |
delay(duration) / delayMs(ms) | Delay first execution. |
retryPolicy(policy) | Retry-backoff curve (below). |
codecs(names...) | Named payload codecs applied to this task's payload. |
circuitBreaker(config) | Guard with a CircuitBreakerConfig — trips after repeated failures (see Circuit breakers below). |
withOptions(options) | Replace the defaults with an EnqueueOptions wholesale. |
Task<EmailPayload> sendEmail = Task.of("send_email", EmailPayload.class)
.queue("emails")
.priority(5)
.retries(3)
.timeout(Duration.ofSeconds(30))
.delay(Duration.ofSeconds(10));There's no native per-task rate_limit field in the Java SDK — throughput is
shaped on the producer side with an enqueue gate instead. See
rate limiting.
Worker resources aren't a Task option either — pull them inside the
handler via Resources.use("name") or an @Resource-annotated parameter.
See dependency
injection.
#[flexiq::task] accepts a closed set of attributes. Anything else is a compile
error that lists the accepted ones:
| Attribute | Value | Default | Effect |
|---|---|---|---|
name | string | the function's name | Task name a job carries, and what a producer in any language enqueues. |
queue | string | "default" | Queue each call is enqueued into. |
priority | integer | 0 | Higher dequeues first within a queue. |
max_retries | integer | 3 | Retries before the job dead-letters. |
retry_backoff_ms | duration | 1s | Base delay of the exponential retry curve. |
retry_max_delay_ms | duration | 5m | Cap on that curve. |
timeout | duration | 5m | Cap on one attempt's execution. |
expires | duration | none | Skip the job if it has not started within the window. |
result_ttl | duration | none | How long the archived result is kept. |
idempotent | bare flag, or = true | false | Derive the dedup key from the task name and payload. |
on_excess | "defer" | "drop" | "defer" | What happens to a job a rate limit turns away. |
max_concurrent | integer | none | Cluster-wide cap on concurrent runs of this task. |
max_in_flight_per_task | integer | none | This task's share of one worker's in-flight slots. |
rate_limit | rate | none | Dispatch rate cap for this task. |
retry_budget | rate | none | Cap on how fast this task may retry, across every job of it. |
cron | string | none | Register the task as a periodic. Six fields, seconds first. |
timezone | string | UTC | IANA name the cron expression is read in. Only meaningful beside cron. |
A duration is 500ms, 30s, 5m, 2h, 1d, or a bare unquoted integer of
milliseconds — timeout = "30s" and timeout = 30_000 are the same thing. A
rate is "<count>/<unit>", the count at least 1 and the unit one of s,
m, h (or second, minute, hour): "100/m", "20/s", "1000/h". Both
grammars are checked while the macro expands, so a typo is a compile error rather
than a worker that starts and quietly never dispatches.
#[flexiq::task(
name = "emails.send",
queue = "emails",
priority = 10,
max_retries = 5,
retry_backoff_ms = "2s",
retry_max_delay_ms = "1m",
timeout = "60s",
rate_limit = "100/m",
max_concurrent = 10,
)]
fn send_email(to: String, subject: String, body: String) -> flexiq::Outcome<()> {
Ok(())
}The set splits in two. queue, priority, max_retries, timeout, expires,
result_ttl and idempotent are enqueue defaults: they seed every
send_email::call(..), and the matching setter on the call overrides them for
one job. The rest is scheduler policy, read once per task when the worker
starts, with no per-call form.
Retry scheduling lives in the shared core, so retries stay durable and survive worker crashes — no in-memory retry state to lose. Backoff is exponential by default: the delay roughly doubles each attempt, up to a configurable cap.
@queue.task(max_retries=5, retry_backoff=2.0)
def flaky_api_call(url):
...queue.task("charge", chargeCard, {
maxRetries: 5,
retryBackoff: { baseMs: 1000, maxMs: 60_000 },
});Task<Order> charge = Task.of("charge", Order.class)
.maxRetries(5)
.retryPolicy(RetryPolicy.exponential(Duration.ofSeconds(1), Duration.ofMinutes(1)));// `retry_max_delay_ms` is the cap on the exponential curve.
#[flexiq::task(max_retries = 5, retry_backoff_ms = "1s", retry_max_delay_ms = "1m")]
fn flaky_api_call(url: String) -> flexiq::Outcome<()> {
fetch(&url).map_err(flexiq::TaskError::retryable)?;
Ok(())
}Whether a failure is retried at all is the body's call, not the macro's: end a
fallible step with .map_err(TaskError::retryable)? to spend a retry, or
.map_err(TaskError::fatal)? to dead-letter immediately.
The cap on the exponential curve is max_retry_delayretryBackoff.maxMsthe max argument to RetryPolicy.exponentialthe retry_max_delay_ms attribute.
Use retry_delays for an exact per-attempt wait schedule instead of
exponential backoff — the final value repeats for any further retries up to
max_retries:
@queue.task(retry_delays=[1, 5, 30]) # 1s after 1st fail, 5s after 2nd, 30s after 3rd
def flaky_api_call():
...retryBackoff only shapes an exponential curve (baseMs, maxMs) — there's
no option for an explicit per-attempt delay list.
RetryPolicy.delays(...) is the explicit per-attempt equivalent — applied
exactly, with no jitter. Supply at least as many delays as maxRetries; once
the list is exhausted, further retries fire immediately:
Task<Order> poll = Task.of("poll", Order.class)
.retries(3)
.retryPolicy(RetryPolicy.delays(
Duration.ofSeconds(5), Duration.ofSeconds(30), Duration.ofMinutes(2)));The attribute set shapes the exponential curve only — there is no attribute for
an exact per-attempt schedule. The field the scheduler reads is
RetryPolicy::custom_delays_ms, so a task that needs one implements
flexiq::Task by hand and returns it from config():
fn config() -> flexiq::TaskConfig {
flexiq::TaskConfig {
retry_policy: flexiq::RetryPolicy {
max_retries: 3,
custom_delays_ms: Some(vec![5_000, 30_000, 120_000]),
..flexiq::RetryPolicy::default()
},
..flexiq::TaskConfig::default()
}
}Explicit delays are applied exactly; the exponential path adds full jitter, so many jobs failing against the same downstream spread out instead of retrying in a wave.
See Retries for the full retry flow and dead-letter behavior.
A timeout bounds a single execution attempt; exceeding it counts as a failure and consumes a retry.
@queue.task(timeout=60)
def long_running():
...queue.task("scrape", scrape, { timeoutMs: 30_000 });Task<String> scrape = Task.of("scrape", String.class)
.timeout(Duration.ofSeconds(30));// A duration is `500ms`, `30s`, `5m`, `2h`, `1d`, or a bare integer of ms.
#[flexiq::task(timeout = "60s")]
fn long_running(url: String) -> flexiq::Outcome<()> {
scrape(&url).map_err(flexiq::TaskError::retryable)?;
Ok(())
}Per call, .timeout_ms(60_000) overrides it. The default is 300 000 ms.
A soft timeout raises SoftTimeoutError only when the task cooperatively
checks — useful for a long loop that should stop cleanly instead of being
killed mid-iteration:
from flexiq import current_job
@queue.task(timeout=300, soft_timeout=60)
def long_running(items):
for item in items:
current_job.check_timeout() # raises SoftTimeoutError if soft_timeout exceeded
process(item)See Timeouts for how a
timeout is surfaced to middleware and events, and how to make an async task
actually stop work when one fires.
See Timeouts — a timeout
releases the job's bookkeeping, not the handler's thread, so long-running
handlers should poll queue.isCancelRequested(jobId) to actually stop.
There is no soft timeout and no cooperative check: a task body receives its decoded arguments and nothing else, so there is no job handle inside it to poll.
The hard timeout is enforced by the scheduler's stale-job sweep, which fails the
job with job timed out after <n>ms and frees its slot — it does not
interrupt the handler, which keeps running on its blocking thread until it
returns. Bound the work inside the body yourself if that matters: give the HTTP
client its own timeout, or break a long loop on a deadline you read at the top.
Trip a task's circuit breaker after repeated failures so a failing downstream
dependency gets time to recover instead of being hammered by retries. The
breaker opens after threshold failures inside a rolling window, then
half-opens after a cooldown to test recovery with a handful of probe jobs.
@queue.task(circuit_breaker={"threshold": 5, "window": 60, "cooldown": 120})
def call_external_api():
...queue.task("call_flaky_api", callApi, {
circuitBreaker: { threshold: 5, windowMs: 60_000, cooldownMs: 120_000 },
});Task<Response> callFlakyApi = Task.of("call_flaky_api", Response.class)
.circuitBreaker(CircuitBreakerConfig.builder(5)
.windowSeconds(60)
.cooldownSeconds(120)
.build());#[flexiq::task] has no circuit-breaker attribute. The field the scheduler
reads is TaskConfig::circuit_breaker, so a task that needs a breaker
implements flexiq::Task by hand and returns one from config():
fn config() -> flexiq::TaskConfig {
flexiq::TaskConfig {
circuit_breaker: Some(flexiq::CircuitBreakerConfig {
threshold: 5,
window_ms: 60_000,
cooldown_ms: 120_000,
half_open_max_probes: 5,
half_open_success_rate: 0.8,
}),
..flexiq::TaskConfig::default()
}
}Half-open recovery is tunable too: half_open_probes / halfOpenMaxProbes /
halfOpenProbes(int) caps how many probe jobs run while half-open (default
5), and half_open_success_rate / halfOpenSuccessRate /
halfOpenSuccessRate(double) is the probe success rate required to close
again (default 0.8).
See Circuit breakers for the full state machine and how to inspect breaker state at runtime.
See Circuit breakers — pair it with retries: retries absorb transient blips, the breaker stops spending that budget on a dependency that's actually down.
Breaker state lives in storage, one row per task, so any process pointed at the
same backend can read it. The read is a Storage method rather than a FlexiQ
one:
use flexiq::{CircuitState, Storage};
if let Some(row) = queue.storage().get_circuit_breaker("call_external_api")? {
println!(
"{:?}, {} failures, opened at {:?}",
CircuitState::from_i32(row.state),
row.failure_count,
row.opened_at
);
}list_circuit_breakers() returns every row at once. CircuitState is
Closed / Open / HalfOpen, and row.state is the raw i32 those decode
from.
max_concurrent caps how many instances of a task run at once, enforced by
the scheduler across every worker on shared storage — not per worker:
@queue.task(max_concurrent=3)
def expensive_render():
...
# At most 3 instances of expensive_render run simultaneously across all workers.maxConcurrent limits how many jobs of a task run at once across all
workers — enforced by the scheduler against the live running count, so it
holds even with many workers on shared storage:
queue.task("transcode", transcode, { maxConcurrent: 2 }); // never more than 2 at onceApply it to a whole queue instead with queue.configureQueue("video", { maxConcurrent: 4 }). See
Concurrency.
Concurrency in the Java SDK is a worker-level setting, not a per-task
field on Task: each worker runs its handlers on a thread pool, and the
pool's size caps how many jobs that worker executes at once.
Worker worker = queue.worker()
.handle(transcode, this::transcode)
.concurrency(2) // at most 2 handlers run at once on this worker
.start();To cap a task globally, give it a dedicated queue and size a single worker to the limit, or serialize the critical section with a distributed lock. See Concurrency.
Two caps, at two scopes. max_concurrent is cluster-wide — the scheduler counts
live runs of the task across every worker on shared storage, at the cost of a
database read. max_in_flight_per_task is this worker's own pool, in-process and
free, and stops one slow task from occupying every slot:
#[flexiq::task(max_concurrent = 3, max_in_flight_per_task = 1)]
fn expensive_render(scene: String) -> flexiq::Outcome<()> {
Ok(())
}The worker's total is num_workers, set on the builder and defaulting to 4 —
that bounds every task together, where the two attributes above bound this one.
Apply middleware to a specific task only, in addition to queue-level middleware:
from flexiq.contrib.sentry import SentryMiddleware
@queue.task(middleware=[SentryMiddleware()])
def important_task():
...Node middleware is registered queue-wide with queue.use(...); there's no
per-task middleware option. Scope logic to a task inside the middleware via
ctx.taskName in before / after, or toggle a global middleware per task
from the dashboard (queue.disableMiddlewareForTask(task, name)).
Java Middleware is registered globally with flexiq.use(...); there's no
per-task attachment. Branch inside the middleware on ctx.taskName().
The Rust SDK has no middleware hooks at all, per task or otherwise. The nearest
seam is flexiq_core::Worker::on_outcome, a callback handed every processed
outcome — success, retry, dead-letter, cancel — which WorkerBuilder does not
expose; building the worker through flexiq_core::Worker directly costs you the
durable-step fence this shell's pool carries. For behaviour that belongs to one
task, put it in the body — the function is your own code and nothing intercepts
it on the way in.
Override the queue-level serializer for a specific task — the full round-trip: arguments are serialized with it at enqueue time and deserialized with it on the worker before the task function is called. Both the sync worker and the native async worker honor the per-task serializer, falling back to the queue-level serializer for tasks that have none registered.
from flexiq.serializers import JsonSerializer
@queue.task(serializer=JsonSerializer())
def api_event(payload: dict) -> dict:
...Useful when a task needs a different format (e.g., human-readable JSON for audit tasks) or when the payload isn't picklable. See Serializers.
The serializer is a queue-level choice (new Queue({ serializer })); it can't
be swapped per task. For per-task payload handling, register named codecs
(compression / encryption / signing) applied on top of the queue serializer:
const queue = new Queue({ dbPath: "myapp.db", codecs: { gzip: new GzipCodec() } });
queue.task("audit_event", handleAudit, { codecs: ["gzip"] }); // transform, not a format swapSee Serializers.
The serializer is a builder-level choice (FlexiQ.builder().serializer(...));
it can't be swapped per task. For per-task payload handling, use
Task.codecs(...) — the same layered-transform model (compression / encryption
/ signing) applied on top of the builder serializer:
Task<AuditEvent> audit = Task.of("audit_event", AuditEvent.class).codecs("gzip");See Serializers.
The payload codec is the wire CBOR envelope, and it is fixed — there is no
serializer to swap, per task or per queue, and no codec layer on top of it. What
varies is the types: any argument that is serde::Serialize encodes, and any
parameter that is serde::de::DeserializeOwned decodes, so the choice a
serializer would give you is made in the function signature instead.
#[derive(serde::Serialize, serde::Deserialize)]
struct AuditEvent {
tenant: String,
amount_cents: i64,
}
#[flexiq::task]
fn api_event(event: AuditEvent) -> flexiq::Outcome<()> {
Ok(())
}The writer runs in binary mode — is_human_readable() reports false — which is
why a type that branches on it, uuid::Uuid being the usual one, round-trips as
bytes rather than as a string. Arguments are positional only, since Rust has
no keyword arguments, and struct fields keep declaration order: sorting them
would change the idempotent dedup key that every language shares.
expires skips a job that wasn't started within the deadline. Set a default
on the task, override it per call, or pass it only at enqueue time:
@queue.task(expires=300) # every job skipped if not started within 5 minutes
def time_sensitive():
...
time_sensitive.delay() # uses the 300s default
time_sensitive.apply_async(args=(), expires=60) # tighter window for this callThe Node SDK has no job-expiration deadline. Job options are delayMs
(earliest start) and timeoutMs (max run time), but a queued job is never
discarded for sitting too long. Gate stale work inside the handler, or cancel
it with queue.cancelJob(id).
The Java SDK has no job-expiration deadline. EnqueueOptions offers delay
(earliest start) and timeout (max run time), but a queued job is never
discarded for sitting too long. Gate stale work inside the handler, or cancel
it with flexiq.cancelJob(id).
expires skips a job that has not started within the deadline. Declare a default
on the task, or set it for one call with .expires_in_ms(..):
#[flexiq::task(expires = "5m")] // skipped if it has not started within 5 minutes
fn time_sensitive(order_id: String) -> flexiq::Outcome<()> {
Ok(())
}
queue.enqueue(time_sensitive::call("ord-1".into()))?; // the 5m default
queue.enqueue(time_sensitive::call("ord-2".into()).expires_in_ms(60_000))?; // tighter, this call onlyBoth are relative and resolved against one now, read once at enqueue, so
expires_at and a delay_ms on the same call cannot disagree by the width of
the call. The sweep that discards an expired job is the scheduler's, so it runs
whether or not a worker ever looked at the queue.
By default, tasks are named using module.qualname:
# In myapp/tasks.py
@queue.task()
def process(): # Named: myapp.tasks.process
...Override with an explicit name:
@queue.task(name="my-custom-name")
def process(): # Named: my-custom-name
...Node task names are always explicit — the required first argument of
queue.task(name, handler). Unlike Python's module.qualname default, there is
no auto-derived name (JS function names are unreliable after bundling).
queue.task("emails.send", (to: string, subject: string) => send(to, subject));
queue.enqueue("emails.send", ["user@example.com", "Welcome"]);Task.of(name, type) takes an explicit name. With the @TaskHandler
annotation, an empty value() defaults the task name to the method name:
@TaskHandler("emails.send") // explicit name
String send(EmailPayload p) { ... }
@TaskHandler // name defaults to the method name: "report"
Report report(List<Metric> m) { ... }A task is named after its function, and name = "..." overrides that:
// In src/tasks/invoices.rs
#[flexiq::task]
fn process() -> flexiq::Outcome<()> { Ok(()) } // named: process
#[flexiq::task(name = "my-custom-name")]
fn process_v2() -> flexiq::Outcome<()> { Ok(()) } // named: my-custom-nameDeliberately not the module path. A path embeds the crate name, so moving the function between modules — or renaming the binary — would change a name a producer in another language has to type verbatim.
Submit with the task's default options:
job = send_email.delay("user@example.com", "Hello", "World")const id = queue.enqueue("sendEmail", ["user@example.com", "Hello", "World"]);String id = flexiq.enqueue(sendEmail, payload); // typed
String id2 = flexiq.enqueue("send_email", payload); // by name, default optionslet job = queue.enqueue(send_email::call(
"user@example.com".into(),
"Hello".into(),
"World".into(),
))?;
// `job` is the stored row: `job.id`, `job.status`, `job.queue`.A wrong argument to call(..) is the same compile error as a wrong argument to
the function.
Override options at enqueue time:
job = send_email.apply_async(
args=("user@example.com", "Hello", "World"),
priority=100, # Override priority
delay=3600, # Run 1 hour from now
queue="urgent-emails", # Override queue
max_retries=10, # Override retries
timeout=120, # Override timeout
unique_key="welcome-user@example.com", # Deduplicate
metadata='{"source": "signup"}', # Attach JSON metadata
)const id = queue.enqueue("sendEmail", ["user@example.com", "Hello", "World"], {
priority: 100, // Override priority
delayMs: 3_600_000, // Run 1 hour from now
queue: "urgent-emails", // Override queue
maxRetries: 10, // Override retries
timeoutMs: 120_000, // Override timeout
uniqueKey: "welcome-user@example.com", // Deduplicate
metadata: JSON.stringify({ source: "signup" }), // Attach metadata
});String id = flexiq.enqueue(sendEmail, payload, EnqueueOptions.builder()
.priority(100) // Override priority
.delay(Duration.ofHours(1)) // Run 1 hour from now
.queue("urgent-emails") // Override queue
.maxRetries(10) // Override retries
.timeout(Duration.ofSeconds(120)) // Override timeout
.uniqueKey("welcome-user@example.com") // Deduplicate
.metadata("{\"source\": \"signup\"}") // Attach metadata
.build());let job = queue.enqueue(
send_email::call("user@example.com".into(), "Hello".into(), "World".into())
.priority(100) // Override priority
.delay_ms(3_600_000) // Run 1 hour from now
.queue("urgent-emails") // Override queue
.max_retries(10) // Override retries
.timeout_ms(120_000) // Override timeout
.unique_key("welcome-user@example.com") // Deduplicate
.metadata(r#"{"source": "signup"}"#), // Attach JSON metadata
)?;The setters live on the call, seeded with whatever the attribute declared, so a per-call value always wins over the task's default.
Calling a task directly runs it synchronously, bypassing the queue — useful in tests:
result = send_email("user@example.com", "Hello", "World") # Runs immediatelyqueue.task() returns the queue, not a task object, so there's nothing to
"call directly." Keep a reference to your handler function and invoke it
directly in tests; only enqueue() routes through the queue.
const sendEmail = async (to: string, subject: string) => { /* ... */ return "sent"; };
queue.task("sendEmail", sendEmail);
const result = await sendEmail("user@example.com", "Hi"); // runs now, bypasses the queueA Task<T> carries no logic to call. Invoke the TaskFunction / handler
method directly for a synchronous run; flexiq.enqueue(task, payload) is the
only path that goes through the queue.
TaskFunction<EmailPayload, String> send = payload -> smtp.deliver(payload);
String result = send.apply(payload); // runs now, bypasses the queueThe macro keeps the original body as run(..), with the function's own
signature, so a task is still callable without a queue — which is how you unit
test one:
let sent = send_email::run("user@example.com".into(), "Hello".into(), "World".into());Its error type is Abort, not QueueError: "this attempt failed" and "the queue
could not be reached" are different things, so ? deliberately does not bridge
them. call(..) mirrors the same signature, which is why a wrong argument to an
enqueue is the same compile error as a wrong argument to the function.
Enqueue many jobs of one task in a single storage round-trip:
jobs = send_email.map([
("alice@example.com", "Hi", "Body"),
("bob@example.com", "Hi", "Body"),
])const ids = queue.enqueueMany("sendEmail", [
{ args: ["alice@example.com", "Hi", "Body"] },
{ args: ["bob@example.com", "Hi", "Body"], options: { priority: 5 } },
]);List<String> ids = flexiq.enqueueMany(sendEmail, List.of(emailA, emailB));// One task per batch: the call's type is the task, and each item keeps its
// own options.
let jobs = queue.enqueue_batch(vec![
send_email::call("alice@example.com".into(), "Hi".into(), "Body".into()),
send_email::call("bob@example.com".into(), "Hi".into(), "Body".into()).priority(5),
])?;One item that cannot encode its arguments fails the whole call and writes nothing. A call carrying a debounce window is refused outright — storage has no batched debounce, so enqueue that one on its own.
Each item can carry its own overrides. See
Batch enqueue
for enqueue_many(), per-item overrides, and per-item result tracking.
Each entry has its own args and options. See
Enqueue options for the
full batch-enqueue contract, and Batching
for the buffering Batcher helper built on top of it.
Unlike the per-item form above, one shared EnqueueOptions applies to the
whole batch — not a list of per-job options. enqueueAll is an alias of
enqueueMany. See Batching for
the buffering Batcher<T> helper built on top of it.
Every item carries its own options, because each item is a call with the
setters on it. What a batch cannot vary is the task: the call's type is the task,
so one enqueue_batch is one task name.
A debounce window is refused rather than split out — storage has no batched debounce, and looping it item by item would cost the atomicity that is the reason to send a batch. Enqueue that one on its own. One item whose arguments cannot encode fails the whole call and writes nothing.
Attach an arbitrary string — commonly JSON — to a job at enqueue time. It's stored with the job and visible in dead letter queue entries:
job = process.apply_async(
args=(data,),
metadata='{"user_id": 42, "source": "api"}',
)queue.enqueue("process", [data], {
metadata: JSON.stringify({ userId: 42, source: "api" }),
});flexiq.enqueue(process, data, EnqueueOptions.builder()
.metadata("{\"userId\": 42, \"source\": \"api\"}")
.build());let job = queue.enqueue(
process::call(data).metadata(r#"{"user_id": 42, "source": "api"}"#),
)?;metadata is stored verbatim as job.metadata: Option<String> — encode the
JSON yourself. .notes(..) is the structured sibling: a JSON object of at most
15 fields.