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>>() {}).
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.
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.
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)));The cap on the exponential curve is max_retry_delay in Python,
retryBackoff.maxMs in Node, and the max argument to
RetryPolicy.exponential in Java.
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)));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 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.
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());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.
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.
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().
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.
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).
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) { ... }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 optionsOverride 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());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 queueEnqueue 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));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.
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());