Middleware
Cross-cutting hooks around enqueue, task execution, and job outcomes — logging, metrics, tracing, and enqueue-time rewrites.
Cross-cutting hooks around enqueue, task execution, and job outcomes — logging, metrics, tracing, and enqueue-time rewrites.
Middleware wraps task execution and reacts to job outcomes — the place for logging, metrics, tracing, and enqueue-time validation that would otherwise be copy-pasted into every task. Where a binding ships a middleware stack, you register it once and it applies across every task, or a specific oneevery taskevery taskevery task; multiple middlewares compose in the order you register them.
The Rust SDK is not one of them: there are no hooks, and nothing to register.
The nearest seam is flexiq_core::Worker::on_outcome, which the rest of this
page keeps returning to.
from flexiq import Queue, TaskMiddleware
class LoggingMiddleware(TaskMiddleware):
def before(self, ctx):
print(f"[START] {ctx.task_name} (job {ctx.id})")
def after(self, ctx, result, error):
status = "OK" if error is None else f"FAILED: {error}"
print(f"[END] {ctx.task_name}: {status}")
queue = Queue(middleware=[LoggingMiddleware()])queue.use({
before: (ctx) => log.info("start", ctx.taskName),
after: (ctx, result) => log.info("ok", ctx.taskName),
onError: (ctx, err) => log.error("threw", ctx.taskName, err),
onRetry: (e) => metrics.inc("retry", e.taskName),
onDeadLetter: (e) => alertOps(e),
});flexiq.use(new Middleware() {
@Override
public void before(TaskContext context) {
log.info("start " + context.taskName);
}
@Override
public void onError(TaskContext context, Throwable error) {
log.error("threw " + context.taskName, error);
}
@Override
public void onDeadLetter(OutcomeEvent event) {
alertOps(event);
}
});The Rust SDK has no middleware: there is nothing to register, and
WorkerBuilder takes tasks and scheduler settings only. The nearest seam is
flexiq_core::Worker::on_outcome, a callback over every outcome the scheduler
records — build the worker through flexiq_core::Worker directly to reach it.
use flexiq::{ResultOutcome, Worker};
let worker = Worker::new(queue.storage().clone())
// Core's worker takes handlers by name, not `#[flexiq::task]` types.
.register("greet", |_job| Ok(None))
.on_outcome(|outcome| match outcome {
ResultOutcome::Success { task_name, .. } => log::info!("[END] {task_name}"),
ResultOutcome::DeadLettered { task_name, error, .. } => {
log::error!("[DEAD] {task_name}: {error}")
}
_ => {}
})
.spawn()?;That is a choice between two workers rather than a combination: this one runs
core's own pool, which drops the (owner, attempt, epoch) fence a durable step
is written under, so a task that calls flexiq::current_step() still needs
queue.worker().
Subclass TaskMiddleware and override only the hooks you need — unimplemented
hooks default to a no-op on the base class.
Middleware is a plain object whose hooks are all optional — implement only
what you need.
Middleware is an interface whose hooks are all no-op default methods —
override only what you need.
WorkerBuilder has no slot for any of this. Its whole surface is
register::<T>(), queues, num_workers, worker_id, scheduler_config and
spawn — tasks and scheduler settings, with nowhere a hook could go.
TaskMiddleware exposes 8 hooks:
| Hook | Called when |
|---|---|
before(ctx) | Before task execution |
after(ctx, result, error) | After task execution (success or failure) |
on_sleep(ctx, wake_at) | An attempt ended in a durable step.sleep, so there is no result to report |
on_retry(ctx, error, retry_count) | A job fails and will be retried |
on_enqueue(task_name, args, kwargs, options) | A job is about to be enqueued |
on_dead_letter(ctx, error) | A job exhausts all retries and moves to the dead letter queue |
on_timeout(ctx) | A job hits its hard timeout |
on_cancel(ctx) | A job is cancelled during execution |
ctx is a JobContext — the same object as current_job — exposing
ctx.id, ctx.task_name, ctx.retry_count, and ctx.queue_name.
on_retry, on_dead_letter, on_timeout, and on_cancel are called by
the Rust result handler after the scheduler records the outcome, firing
after after() and after the corresponding event is emitted on the event
bus. Exceptions raised inside these hooks are logged and do not affect job
processing.
on_timeout fires when the maintenance reaper detects a stale job that
exceeded its hard timeout — before on_retry (if it will be retried)
or on_dead_letter (if retries are exhausted), so you can react to the
timeout itself independently of the job's eventual fate.
Middleware exposes 9 hooks:
| Hook | When | Awaited |
|---|---|---|
onEnqueue(ctx) | Producer-side, inside enqueue, before serialization. | no (sync) |
before(ctx) | Before each execution attempt. | yes |
after(ctx, result) | After a successful attempt. | yes |
onSleep(ctx, wakeAt) | An attempt ended in a durable step.sleep, so there is no result to report. | yes |
onError(ctx, err) | When an attempt throws (before the retry/dead decision). | yes |
onCompleted(e) | After a job completes successfully. | no |
onRetry(e) | After the core schedules a retry. | no |
onDeadLetter(e) | After a job dead-letters. | no |
onCancel(e) | After a job is cancelled. | no |
The execution hooks (before/after/onError) receive a TaskContext with
taskName, jobId, and args. The outcome hooks receive an OutcomeEvent
with taskName, jobId, queue, retryCount, timedOut (which separates a
timeout from other failures), and durationMs (absent when nothing measured
the run).
Middleware exposes 9 hooks:
| Hook | When |
|---|---|
onEnqueue(context) | Producer-side, inside enqueue, before serialization. |
before(context) | Before each execution attempt. |
after(context, result) | After a successful attempt. |
onSleep(context, wakeAt) | An attempt ended in a durable step.sleep, so there is no result to report. |
onError(context, error) | When an attempt throws (before the retry/dead decision). |
onCompleted(event) | After a job completes successfully. |
onRetry(event) | After the core schedules a retry. |
onDeadLetter(event) | After a job dead-letters. |
onCancel(event) | After a job is cancelled. |
The execution hooks receive a TaskContext with taskName, jobId, a
mutable per-execution attributes() map shared across a job's hooks,
job() with lazily-loaded metadata, and elapsedMs() — time spent on this
execution so far, so after/onError needn't start their own timer. The
outcome hooks receive an OutcomeEvent with taskName, jobId, error,
retryCount, timedOut (which separates a timeout from other failures), and
durationMs() (null when nothing measured the run).
Zero hooks. What on_outcome gives you instead is one callback over the
ResultOutcome enum, whose arms line up with the back half of a hook list and
nothing at all with the front:
| Hook elsewhere | Nearest arm, and what it carries |
|---|---|
before | Nothing. No code runs between the claim and the body — the body's first line is the seam. |
after | Success — job_id, task_name, wall_time_ns. No queue, and no result value. |
on_sleep | Slept — job_id, task_name, queue, wake_at, wall_time_ns. |
on_error / on_retry | Retry — adds error, retry_count (the attempt number after this failure) and timed_out. |
on_dead_letter | DeadLettered — the same, minus retry_count. |
on_cancel | Cancelled — job_id, task_name, queue, wall_time_ns. |
on_timeout | No arm of its own. timed_out on Retry and DeadLettered is the whole signal. |
on_enqueue | Nothing. The producer side runs no callback at any point. |
A seventh arm has no counterpart at all: Superseded { job_id } is a result the
job has already moved past — a reclaimed owner finishing late — and reporting it
would double-count. ResultOutcome is #[non_exhaustive], so a match on it
outside the core crate needs a trailing _ arm — the compiler will not let you
enumerate the variants, which is how a later outcome gets ignored instead of
breaking your build.
on_enqueueonEnqueueonEnqueueWhere a binding has one, its enqueue hook is unique among the hooks: it fires
before the job is written to storage, and the context it receives is
mutable — use it to validate, redact, or reshape what actually gets
enqueued.
class PriorityBoostMiddleware(TaskMiddleware):
def on_enqueue(self, task_name, args, kwargs, options):
# Bump priority for urgent tasks during business hours
if task_name.startswith("alerts."):
options["priority"] = max(options.get("priority", 0), 50)queue.use({
onEnqueue: (ctx) => {
// Bump priority for urgent tasks
if (ctx.taskName.startsWith("alerts.")) {
ctx.options.priority = 50;
}
},
});flexiq.use(new Middleware() {
@Override
public void onEnqueue(EnqueueContext context) {
// Bump priority for urgent tasks
if (context.taskName.startsWith("alerts.")) {
context.options(context.options().toBuilder().priority(50).build());
}
}
});There is no enqueue hook to rewrite — on_outcome fires after a job has run,
and nothing at all runs before the write. Enqueue options belong to the call, so
the way to apply one everywhere is a function that returns the configured
TaskCall.
use flexiq::TaskCall;
#[flexiq::task(queue = "alerts")]
fn page_oncall(message: String) -> flexiq::Outcome<()> {
println!("paging: {message}");
Ok(())
}
/// Every page enqueues through here, so the priority is set in one place.
fn urgent(call: TaskCall<page_oncall>) -> TaskCall<page_oncall> {
call.priority(50)
}
queue.enqueue(urgent(page_oncall::call("disk full".into())))?;Keys present in options: priority, delay, queue, max_retries,
timeout, unique_key, metadata.
EnqueueContext also exposes mutable args, and throwing from onEnqueue
aborts the enqueue. This is the same producer-side seam as
enqueue interceptors,
which run first and can convert, redirect, or reject the call outright before
onEnqueue sees it.
EnqueueContext also exposes payload()/payload(...) to replace the job's
payload outright, and a mutable metadata() map that travels with the job
(readable at execution via context.job().metadata()). When the options
already carry metadata that is a JSON object, the hook's map is layered over
it (hook keys win) instead of replacing it. Throwing from
onEnqueue aborts the enqueue. For typed convert/redirect/reject decisions,
use an interceptor
instead — interceptors run before middleware onEnqueue.
The other place a default can live is the declaration. Task::defaults()
returns an EnqueueOptions seeded from the #[flexiq::task] attribute, and
every TaskCall starts from it — so queue, priority, max_retries and
timeout set on the attribute already apply to every call without a wrapper.
What there is no API for is the reverse: a prebuilt EnqueueOptions cannot be
handed to a TaskCall that already exists. Overrides are per-call setters, or
a helper function like the one above.
Queue(middleware=[...])) runs first@queue.task(middleware=[...])) runs secondafter() and the Rust-dispatched outcome hooks run in that same
forward order, not reversed — after() only fires for middleware whose
before() didn't raise.Every hook — before, after, onError, and the outcome hooks — runs in
the same registration order for every middleware; the Node SDK doesn't
reverse after the way an "onion" middleware model would.
Multiple middlewares run in registration order for every phase — before,
after, onError, and the outcome hooks are not reversed.
Nothing to order: there is one callback, and calling on_outcome twice replaces
it rather than appending. The only sequence that exists is the worker's — the
body runs on a blocking thread, its JobResult goes down a channel, and the
drain thread hands the scheduler a batch of them and calls the callback once per
outcome the scheduler returned, in that order.
If a middleware hook raises an exception:
before(): the exception is logged, but subsequent middleware before() hooks still run. The task executes normally.after() / on_sleep(): the exception is logged. The other hooks in the chain still run.on_retry() / on_dead_letter() / on_timeout() / on_cancel(): logged and swallowed — these are notification hooks, not control flow.Middleware exceptions never prevent task execution or result handling.
The execution hooks (before/after/onError) are awaited in the hot path:
a rejected before or after fails the attempt like a thrown handler error.
onError and onSleep are each wrapped per middleware, so a throwing
onError never masks the original task failure and a throwing onSleep never
un-commits a sleep that already happened; both are logged at debug. The outcome hooks (onCompleted/onRetry/onDeadLetter/
onCancel) are isolated per middleware — one that throws or rejects is
logged at debug and doesn't stop the rest from running.
The execution hooks run inside the attempt — a before or after that
throws fails the attempt like a handler error, and is caught by the same
onError path. onError and onSleep are isolated per middleware instead: one
that throws is caught and logged, because escaping there would leave the
dispatch with no outcome to report at all. Outcome hooks are isolated for the
same reason.
The two sides are not isolated the same way, and the difference matters.
A panic inside a task body is contained: the shell's pool catches it and
records a retryable failure naming the task and the panic message, and the
blocking thread is reused afterwards. A panic inside an on_outcome callback is
not caught by anything — it unwinds the worker's drain thread, and results stop
being handled from that point on. Treat the callback as code that must not
panic: no unwrap on anything the outcome did not already hand you.
A task's timeout bounds its handler. It does not bound the middleware around it, so a hook that blocks — an exporter flushing to an unreachable collector, a tracing backend with no client-side timeout — holds the attempt open past the task's own limit. Where those hooks exist they therefore carry a budget of their own, per hook call, defaulting to 5 seconds:
# 2s per before()/after()/on_sleep() call; 0 disables the check
queue = Queue(middleware=[LoggingMiddleware()], middleware_timeout=2.0)// 2s per before/after/onError/onSleep call; 0 disables the bound
const queue = new Queue({ middlewareTimeoutMs: 2000 });// 2s per before/after/onError/onSleep call; Duration.ZERO disables the bound
FlexiQ flexiq = FlexiQ.builder()
.sqlite("app.db")
.middlewareTimeout(Duration.ofSeconds(2))
.open();Nothing to bound: with no hooks in the attempt, a task's own timeout is the
only budget around the body.
#[flexiq::task(timeout = "30s")]
fn export(report_id: i64) -> flexiq::Outcome<()> {
println!("exporting {report_id}");
Ok(())
}An on_outcome callback sits outside the attempt rather than inside it: it runs
on the worker's drain thread once the outcome is already recorded, so a slow one
delays result handling for every other job instead of failing this one.
Going over budget is never a job failure: the overrun is logged against the middleware that caused it and the rest of the chain still runs. Failing an attempt over its instrumentation is the failure mode these hooks exist to avoid — and a hook that throws on its own still behaves exactly as Exception handling describes.
When the chain resumes differs by SDK, because what a runtime can do to a hook that will not return differs. Only Node moves on at the deadline; Python waits for the hook regardless, and Java waits too whenever the hook ignores the interrupt. Read the budget as "when you will be told", and on Python and Java as "when the attempt will be tried".
A hook that overruns on the sleep path is the expensive one. An attempt that
ended in a durable step.sleep is already pending at its wake-up deadline and
holds no claim, so nothing will ever reap it — unlike a hung after, whose job
a stale-job sweep eventually fails. Close what you opened and return.
The hook keeps its thread: a middleware may push a thread-local scope in
before for the handler to run inside, so moving the call somewhere it could be
abandoned would break it. The budget therefore reports an overrun rather than
ending it — the log names the middleware and the hook, and the attempt stays
blocked until the hook returns. Under the prefork pool the attempt is bounded
regardless: a child that outruns its task's timeout is killed, hooks included.
Past the budget the chain stops awaiting the hook and moves on. Nothing can
cancel a promise, so the hook itself keeps running; what ends is the attempt's
wait for it. A rejection that arrives after that point is logged at debug.
The hook keeps the dispatch thread — a middleware may open a thread-bound scope
in before for the handler and close it from after — so the budget is
enforced by interrupting that thread. Blocking that honours interrupts
(Future.get, awaitTermination, a socket with a timeout) ends there; anything
that ignores them runs on, and the overrun is logged either way. The interrupt
flag is cleared before the handler runs, so it never inherits one.
There is no budget to set, because there is nothing in the attempt to bound but
the body itself — and a task's timeout does not interrupt that either. Nothing
cancels a running handler: the deadline is noticed by the stale-job reap, which
records the timeout and lets the job retry while the abandoned thread keeps its
slot until the body returns.
The unbounded thing to watch is the callback. on_outcome is one closure shared
by every job on the worker, run on the single drain thread, with no deadline
around it — a call that blocks there does not slow one attempt down, it stops the
worker recording any outcome at all. Hand slow work to a channel and return.
Apply middleware to a specific task using the middleware parameter on
@queue.task:
@queue.task(middleware=[MetricsMiddleware()])
def process(data):
...Per-task middleware runs after global middleware, in registration order.
There's no separate per-task registration — every middleware passed to
queue.use() applies to every task. Scope a middleware's own hooks to
specific tasks by checking ctx.taskName inside them, or by giving the
middleware its own task-name filter (as the OpenTelemetry
integration does with taskFilter).
There's no separate per-task registration — every middleware passed to
flexiq.use() applies to every task. Scope a middleware's own hooks to
specific tasks by checking context.taskName inside them.
No per-task registration either, and nowhere to put one: #[flexiq::task]
accepts a fixed key set and an unrecognised key is a compile error that lists
the ones it takes. Scoping happens inside the single callback, by matching on
task_name before doing anything — every arm except Superseded carries it.
Coming from Celery signals? Use these instead of connecting to task_prerun /
task_postrun / task_failure: queue-level hooks for global behavior, or
per-task middleware for scoped behavior.
flexiq has two systems for running code around tasks:
Hooks (@queue.on_failure, etc.) | Middleware (TaskMiddleware) | |
|---|---|---|
| Scope | Queue-level only | Queue-level or per-task |
| Interface | Decorated functions | Class with up to 7 hooks |
| Context | Receives task_name, args, kwargs | Receives JobContext |
| Enqueue hook | No | Yes (on_enqueue, can mutate options) |
| Retry hook | No | Yes (on_retry) |
| DLQ / timeout / cancel hooks | No | Yes |
| Execution order | After middleware | Before hooks |
Middleware runs inside the task wrapper (closer to the task function),
while hooks run outside. In practice, middleware before() fires
first, then before_task hooks. On completion, on_success/on_failure
hooks fire, then middleware after().
Where a binding ships built-in observability integrations, middleware is how they attach to task execution — compose them with your own middleware freely.
from flexiq import Queue
from flexiq.contrib.otel import OpenTelemetryMiddleware
queue = Queue(middleware=[
OpenTelemetryMiddleware(),
LoggingMiddleware(),
])import { otelMiddleware } from "@byteveda/flexiq/contrib/otel";
queue.use(otelMiddleware());
queue.use({ before: (ctx) => log.info("start", ctx.taskName) }); // your own middlewareimport org.byteveda.flexiq.contrib.FlexiQObservation;
flexiq.use(new FlexiQObservation(registry));
flexiq.use(new Middleware() { // your own middleware
@Override
public void before(TaskContext context) {
log.info("start " + context.taskName);
}
});The Rust SDK ships no observability integrations, and with no middleware there
is nothing for them to compose with. ResultOutcome carries most of what an
exporter wants — every arm has task_name and wall_time_ns, and the failure
arms add queue, error and retry_count — so one
flexiq_core::Worker::on_outcome callback feeds your own recorder. Success is
the narrow one: job_id, task_name and wall_time_ns, no queue label.
use flexiq::{ResultOutcome, Worker};
let worker = Worker::new(queue.storage().clone())
.register("export", |_job| Ok(None))
.on_outcome(|outcome| {
if let ResultOutcome::Success { task_name, wall_time_ns, .. } = outcome {
log::info!("{task_name} took {}ms", wall_time_ns / 1_000_000);
}
})
.spawn()?;See the OpenTelemetry guide for setup details.
See the OpenTelemetry and Sentry integration guides for setup details.
See the Micrometer and Sentry integration guides for setup details.
There are no integration guides to point at, because there are no integrations.
Monitoring covers the two real
sources — the callback and queue.stats() —
Events documents every field on
ResultOutcome and what taking that route costs a task with durable steps, and
Logging covers what the engine
already emits without any of this.