Troubleshooting
Diagnose stuck jobs, unresponsive workers, growing databases, latency, and missing tasks.
Diagnose stuck jobs, unresponsive workers, growing databases, latency, and missing tasks.
Common issues and how to fix them, organized by symptom.
Turn on debug logs first — they show the worker claiming, running, and settling jobs:
import { setLogLevel } from "@byteveda/flexiq";
setLogLevel("debug");
// or: FLEXIQ_LOG_LEVEL=debug node app.jsTurn on debug logs first — they show the worker claiming, running, and settling jobs:
FlexiQLogger.setLevel(LogLevel.DEBUG);
// or: FLEXIQ_LOG_LEVEL=debug java -jar app.jarTurn on debug logs first — the Rust core's own logging is wired through
RUST_LOG, independent of Python's logging module:
RUST_LOG=flexiq_core=debug,flexiq_python=debug flexiq worker --app myapp:queueTurn on debug logs first — the engine logs through the log facade, so a
binary that installs no implementation prints nothing at all:
fn main() -> flexiq::Result<()> {
env_logger::init(); // RUST_LOG=flexiq=debug,flexiq_core=debug
// ...
Ok(())
}Any log implementation works; the filter names the crates, not the SDK.
A few checks, in order:
"default"); a job enqueued to a different queue is invisible to
it. Match them.queue.list_paused_queues()
queue.resume_queue("default")queue.listPausedQueues();
queue.resumeQueue("default");flexiq.listPausedQueues();
flexiq.queue("default").resume();// `FlexiQ` does not wrap queue pausing; the backend under it does.
use flexiq::Storage;
queue.storage().list_paused_queues()?;
queue.storage().resume_queue("default")?;runningSymptom: jobs stay in running status long after they should have
finished.
Diagnosis:
stats = queue.stats()
print(stats) # {'running': 47, 'pending': 0, ...}
stuck = queue.list_jobs(status="running", limit=20)
for job in stuck:
d = job.to_dict()
print(f"{d['id']} | {d['task_name']} | started {d['started_at']}")const stats = await queue.stats();
console.log(stats); // { running: 47, pending: 0, ... }
const stuck = await queue.listJobs({ status: "running", limit: 20 });
for (const job of stuck) {
console.log(`${job.id} | ${job.taskName} | started ${job.startedAt}`);
}QueueStats stats = flexiq.stats();
System.out.println(stats.running + " running, " + stats.pending + " pending");
List<Job> stuck = flexiq.listJobs(
JobFilter.builder().status(JobStatus.RUNNING).limit(20).build());
for (Job job : stuck) {
System.out.println(job.id + " | " + job.taskName + " | started " + job.startedAt);
}use flexiq::{JobStatus, Storage};
let stats = queue.stats()?;
println!("{} running, {} pending", stats.running, stats.pending);
// `FlexiQ::list_jobs` takes only a limit and an offset; the filtered listing
// is the backend's, and its rows come back blob-free.
let stuck = queue
.storage()
.list_jobs(Some(JobStatus::Running as i32), None, None, 20, 0, None)?;
for job in stuck {
println!("{} | {} | started {:?}", job.id, job.task_name, job.started_at);
}Cause: the worker process that claimed the job crashed or hung before marking it complete. Two independent recovery mechanisms exist, and neither depends on the other:
A job stuck because its worker is alive but hung — deadlocked, stuck in an infinite loop, blocked forever on I/O — has only the first mechanism to save it. Always set a timeout on production tasks:
@queue.task(timeout=300) # 5 minutes max
def process_data(payload):
...queue.task("processData", (payload) => processData(payload), { timeoutMs: 300_000 });Task<Payload> PROCESS_DATA = Task.of("process_data", Payload.class)
.timeout(Duration.ofMinutes(5));#[flexiq::task(timeout = "5m")] // also 500ms / 30s / 2h / 1d, or a bare integer in ms
fn process_data(payload: String) -> flexiq::Outcome<()> {
let _ = payload;
Ok(())
}timeout_ms is the internal, database-stored deadline in milliseconds; the
timeout= kwarg above takes seconds and flexiq converts it under the
hood.
timeout_ms is the database-stored deadline in milliseconds, and the
timeout attribute is a duration literal that expands to it — a bare integer
there is already milliseconds, so timeout = 30 is 30 ms, not 30 seconds.
Per call, .timeout_ms(30_000) overrides it.
The deadline is enforced by the scheduler's stale-job sweep, not by interrupting the handler: the body runs to completion on its blocking thread and its late result is dropped by the attempt fence.
A job without a timeout is only recovered automatically if its worker process actually dies. A worker that's alive but hung holds the job forever — nothing detects that case without a timeout.
For that last case — a job neither recovery mechanism will touch — force it
back to pending manually:
queue.requeue_job(job_id) # True if it was Running and is now Pendingrequeue_job() releases the job's execution claim so a healthy worker can
pick it up, and preserves the retry budget. Only use it when the owning
worker is confirmed dead or hung: if the old attempt is actually still
running, it may finish later and the job executes twice.
For that last case — a job neither recovery mechanism will touch — force it
back to pending manually:
queue.requeueJob(jobId); // true if it was running and is now pendingrequeueJob() releases the job's execution claim so a healthy worker can
pick it up, and preserves the retry budget. Only use it when the owning
worker is confirmed dead or hung: if the old attempt is actually still
running, it may finish later and the job executes twice.
For that last case — a job neither recovery mechanism will touch — force it
back to pending manually:
flexiq.requeueJob(jobId); // true if it was running and is now pendingrequeueJob() releases the job's execution claim so a healthy worker can
pick it up, and preserves the retry budget. Only use it when the owning
worker is confirmed dead or hung: if the old attempt is actually still
running, it may finish later and the job executes twice.
For that last case — a job neither recovery mechanism will touch — force it
back to pending manually. FlexiQ does not wrap this one either:
use flexiq::Storage;
// true if it was Running and is now Pending
queue
.storage()
.requeue_stuck(&job_id, flexiq::now_millis())?;requeue_stuck releases the job's execution claim so a healthy worker can
re-claim it, preserves the retry budget, and clears any pending cancel
request. Only use it when the owning worker is confirmed dead or hung: if the
old attempt is actually still running, it may finish later and the job
executes twice.
Symptom: the worker process is alive but not processing jobs; its heartbeat is stale.
Diagnosis:
workers = queue.workers()
for w in workers:
print(f"{w['worker_id']}: {w['status']} (last seen: {w['last_heartbeat']})")const workers = await queue.listWorkers();
for (const w of workers) {
console.log(`${w.workerId}: ${w.status} (last seen: ${w.lastHeartbeat})`);
}List<WorkerInfo> workers = flexiq.listWorkers();
for (WorkerInfo w : workers) {
System.out.println(w.workerId + ": " + w.status + " (last seen: " + w.lastHeartbeat + ")");
}use flexiq::Storage;
for w in queue.storage().list_workers()? {
// last_heartbeat is Unix milliseconds
println!("{}: {} (last seen: {})", w.worker_id, w.status, w.last_heartbeat);
}Possible causes, not tied to any one runtime:
GIL-bound CPU task: a long-running CPU task is holding the GIL (Global Interpreter Lock — only one thread runs Python bytecode at a time), blocking the scheduler thread from dispatching new jobs. The scheduler runs in Rust, but it still needs the GIL to call Python functions.
Fix: switch to the prefork pool for CPU-bound tasks — it runs tasks in separate subprocesses instead of threads, so one task's GIL usage can't block the scheduler.
flexiq worker --app myapp:queue --pool preforkNode handlers share one thread — the event loop. A long synchronous
computation blocks every in-flight job, not just its own (see
Execution Models). Offload CPU-bound
work to a worker_threads pool (or a native addon) and await it, or set a
timeoutMs so a stuck attempt is
aborted — the timeout stops tracking the attempt, but a synchronous handler
still won't yield the loop until it returns.
Handlers run on real JVM threads (see
Execution Models), so one
hung handler doesn't stall the others under the default cached pool — it
only ties up its own thread. If you sized the pool with concurrency(n)
though, enough hung handlers exhaust it and everything else queues up
behind them.
close() drains in-flight handlers for 30 seconds, then interrupts and
waits 30 more before closing the native worker — a stuck handler delays
shutdown by up to a minute. Set a task timeout so a stuck attempt is reaped,
and keep handlers interruptible.
Task bodies run on spawn_blocking threads behind a semaphore sized by
num_workers, so one hung body ties up its own slot and nothing else — until
enough of them hang that the semaphore is exhausted and dispatch stops.
#[flexiq::task] refuses async fn for the same reason: every body is
synchronous, so none of them can wedge a runtime thread the scheduler needs.
A body that panics is not lost. The dispatch catches the unwind and reports a retryable failure carrying the panic payload:
task `process_data` panicked: called `Option::unwrap()` on a `None` valueSo a panic follows the retry policy like any other failure — which also means a
panic over state that stays broken (a poisoned Mutex, say) retries into the
same panic until the job dead-letters. That is the shape to look for when a task
fails identically on every attempt.
Set a task timeout so a stuck attempt is reaped. A macro-expanded body receives its arguments and nothing else, so there is no cancellation flag for it to poll — a long body that must be interruptible has to carry its own.
Cause: the task name recorded on a job at enqueue time doesn't match a name the worker has registered. Task functions never travel through the queue — only their names and arguments do — so the worker's registry has to be populated independently.
Worker logs raise KeyError: "task 'myapp.tasks.process' not registered".
Task names default to module.function_name; if you enqueue from one
import path and run the worker with a different one, the names won't match.
# Check the task name stored in the job
job = queue.get_job(job_id)
print(job.to_dict()["task_name"]) # e.g. "myapp.tasks.process"
# Check what the worker has registered
print([t["name"] for t in queue.registered_tasks()])Fix: use consistent import paths. If the task is myapp/tasks.py:process,
always import it as myapp.tasks.process — not tasks.process (relative)
or src.myapp.tasks.process (with an src prefix).
You can also set an explicit name to decouple the task name from the module path:
@queue.task(name="process-data")
def process(payload):
...The worker dequeued a job whose task name was never registered on it —
it throws TaskNotRegisteredError (No task registered with name "...").
Register the handler (queue.task(name, fn)) on the worker process.
The worker dequeued a job whose task name has no handler on it — the job
fails with no handler registered for task '...'. Register the handler
(handle(name, payloadType, fn)) on the worker process.
The worker dequeued a job whose task name it has no body for. The job fails fatally — no retry, because no amount of retrying makes an unregistered task runnable here:
task not registered on this worker: process_dataDeclaring the task is not registering it. Every task the worker should run needs
its own register call on the builder:
let worker = queue
.worker()
.register::<process_data>() // the name is the task, not a string
.spawn()?;A task's default name is the function name, not its module path, so two
process_data functions in different modules are one task name. Registering
both on one worker is refused at spawn() rather than silently dropping a body:
task `process_data` was registered twice on one worker: one of the two bodies would never runGive one an explicit name to separate them — #[flexiq::task(name = "reports::process_data")].
If the same task succeeds on one worker and dead-letters on another, the registries differ rather than the task being unregistered everywhere. That is the shape a partial task discovery produces — a stale image, a replica that missed a deploy, or a task module left out of the built artifact. Compare what each worker actually registered before changing any task code, and see Executors disagree on their task registry below for the warning the scheduler logs when it can see the difference itself.
Symptom: the scheduler logs a warning at attach, and jobs for some task
names sit unplaced or fail with was not dispatched.
[flexiq] executor python-executor-0198f3d2-4c71-7a3e-b8d5-1f2a9c4e7b60 (python 1.0.0) advertises task registry
eb5ed0d43c8f2aaa, but executor python-executor-0198f3d1-9e05-7c42-a1b7-6d3f08ce5142 (python 1.0.0) advertises
0d49390f67ebbff1; a job for a task only one of them knows fails wherever it lands.
only on python-executor-0198f3d2-4c71-7a3e-b8d5-1f2a9c4e7b60: (none). only on python-executor-0198f3d1-9e05-7c42-a1b7-6d3f08ce5142:
reports.build, reports.export[flexiq] executor node-executor-0198f3d2-4c71-7a3e-b8d5-1f2a9c4e7b60 (node 1.0.0) advertises task registry
eb5ed0d43c8f2aaa, but executor node-executor-0198f3d1-9e05-7c42-a1b7-6d3f08ce5142 (node 1.0.0) advertises
0d49390f67ebbff1; a job for a task only one of them knows fails wherever it lands.
only on node-executor-0198f3d2-4c71-7a3e-b8d5-1f2a9c4e7b60: (none). only on node-executor-0198f3d1-9e05-7c42-a1b7-6d3f08ce5142:
reports.build, reports.export[flexiq] executor java-executor-0198f3d2-4c71-7a3e-b8d5-1f2a9c4e7b60 (java 1.0.0) advertises task registry
eb5ed0d43c8f2aaa, but executor java-executor-0198f3d1-9e05-7c42-a1b7-6d3f08ce5142 (java 1.0.0) advertises
0d49390f67ebbff1; a job for a task only one of them knows fails wherever it lands.
only on java-executor-0198f3d2-4c71-7a3e-b8d5-1f2a9c4e7b60: (none). only on java-executor-0198f3d1-9e05-7c42-a1b7-6d3f08ce5142:
reports.build, reports.exportThe Rust SDK ships no executor command — queue.worker() builds an in-process
worker. Attaching is flexiq::ExecutorClient over a WorkerDispatcher you
write, and the label this warning prints is whatever ExecutorConfig::sdk that
binary announced:
[flexiq] executor rust-executor-0198f3d2-4c71-7a3e-b8d5-1f2a9c4e7b60 (rust 2.0.0) advertises task registry
eb5ed0d43c8f2aaa, but executor rust-executor-0198f3d1-9e05-7c42-a1b7-6d3f08ce5142 (rust 2.0.0) advertises
0d49390f67ebbff1; a job for a task only one of them knows fails wherever it lands.
only on rust-executor-0198f3d2-4c71-7a3e-b8d5-1f2a9c4e7b60: (none). only on rust-executor-0198f3d1-9e05-7c42-a1b7-6d3f08ce5142:
reports.build, reports.exportThe registry it compares is ExecutorConfig::tasks — the list that binary
passed, not anything derived from what it can actually run. A name left out of
that list is a job the scheduler never sends.
Cause: two attached
executors advertised different task lists. The scheduler routes a task
name only to peers that advertised it, so a job is never handed to one that
cannot run it — instead a name only part of the fleet knows depends on that part
staying attached with a free slot. When none is available the job waits, and
past the placement timeout it fails retryably with task '...' was not dispatched: no attached executor advertises it. (none) on one side means that
registry is a strict subset of the other's — the shape a half-finished discovery
produces.
Fix: read the two only on lists — they name the tasks at risk. If the
peers are meant to be interchangeable, compare the two deployments: a rollout in
progress resolves itself, a stale replica or a task module missing from the
image does not. The unplaced jobs retry, so they drain on their own once a peer
advertising those names attaches; check the dead-letter queue for any that
exhausted their retries first. The warning is a diagnostic, never a gate — a
divergent executor still attaches and still receives work.
The warning fires once per distinct registry, not once per executor, so rolling a fleet onto a new task list logs a single line. See Task discovery for the full rule and the fingerprint it compares.
SQLite database is lockedSQLite allows many concurrent readers (WAL mode — write-ahead logging) but only one writer at a time. Under many concurrent workers writing to the same file, this surfaces as lock contention.
OperationalError: database is locked
flexiq sets busy_timeout=5000ms to wait for locks, but heavy write loads
can still cause contention.
Causes:
Fixes:
enqueue_many() / task.map()
for batch inserts — they use a single transactionenqueue_batch is one transaction per batch, enqueue
is one per jobnum_workers, orThe connection pool is 8 by default. FlexiQ::open takes that default;
FlexiQ::from_storage is where another number goes, though the single-writer
constraint caps write throughput regardless of pool size:
use flexiq::{FlexiQ, SqliteStorage, StorageBackend};
let storage = SqliteStorage::with_pool_size("/var/lib/myapp/flexiq.db", 16)?;
let queue = FlexiQ::from_storage(StorageBackend::Sqlite(storage))?;Symptom: the SQLite file keeps growing, or the job tables never shrink on Postgres/Redis. Completed job records and their result payloads accumulate unless something purges them.
Fix: purge old records.
# Purge completed jobs older than 7 days (older_than is in seconds)
queue.purge_completed(older_than=604800)
# Purge dead-lettered jobs older than 30 days
queue.purge_dead(older_than=2592000)// Purge completed jobs older than 7 days (olderThanMs is in milliseconds)
await queue.purgeCompleted(7 * 24 * 60 * 60 * 1000);
// Purge dead-lettered jobs older than 30 days
await queue.purgeDead(30 * 24 * 60 * 60 * 1000);// Purge completed jobs older than 7 days (olderThanMs is in milliseconds)
flexiq.purgeCompleted(Duration.ofDays(7).toMillis());
// Purge dead-lettered jobs older than 30 days
flexiq.purgeDead(Duration.ofDays(30).toMillis());// `FlexiQ` does not wrap the purges; the backend under it does.
use flexiq::Storage;
// Purge completed jobs older than 7 days (older_than_ms is in milliseconds)
queue.storage().purge_completed(7 * 24 * 60 * 60 * 1_000)?;
// Purge dead-lettered jobs older than 30 days
queue.storage().purge_dead(30 * 24 * 60 * 60 * 1_000)?;Both return the number of rows removed.
Set result_ttl on the Queue to auto-purge going forward instead of
purging manually every time:
queue = Queue(
db_path="myapp.db",
result_ttl=86400, # Purge completed/dead jobs older than 24 hours
)There's no built-in TTL option — run the purge above on a schedule instead (a cron job, or a periodic task on the queue itself).
There's no built-in TTL option — run the purge above on a schedule instead (a cron job, or a periodic task on the queue itself).
Retention is a scheduler setting, so a worker can sweep for you instead of you
purging by hand. RetentionConfig carries one window per table —
uniform(ms) sets them all to the same one:
use flexiq::{RetentionConfig, SchedulerConfig};
let worker = queue
.worker()
.register::<process_data>()
.scheduler_config(SchedulerConfig {
retention: Some(RetentionConfig::uniform(24 * 60 * 60 * 1_000)),
..SchedulerConfig::default()
})
.spawn()?;RetentionConfig::recommended() is the per-table set instead of one window for
everything — dead letters outlive task logs, which is usually what you want. The
sweep runs on the scheduler's cleanup_interval tick, so it needs a worker
running; a producer-only process cleans nothing.
After purging, reclaim disk space (SQLite only — Postgres reclaims space via autovacuum, Redis via key expiry):
sqlite3 myapp.db "VACUUM;"VACUUM rewrites the entire database and requires exclusive access. Run
it during low-traffic periods.
Symptom: jobs sit in pending for longer than expected before starting.
Diagnosis: check the queue depth first.
stats = queue.stats()
print(f"Pending: {stats['pending']}, Running: {stats['running']}")const stats = await queue.stats();
console.log(`Pending: ${stats.pending}, Running: ${stats.running}`);QueueStats stats = flexiq.stats();
System.out.println("Pending: " + stats.pending + ", Running: " + stats.running);let stats = queue.stats()?;
println!("Pending: {}, Running: {}", stats.pending, stats.running);Possible causes and fixes:
Scheduler poll interval too high: default is 50ms. Jobs can wait up to one poll interval before being picked up.
queue = Queue(scheduler_poll_interval_ms=10) # Poll every 10msLower values increase CPU/DB usage. Balance based on your latency requirements.
Not enough worker threads: all workers are busy. Increase the pool size.
queue = Queue(workers=16)Rate limiting: the task or queue has a rate limit active — rate-limited jobs are rescheduled 1 second into the future.
pending = queue.list_jobs(status="pending", limit=10)
for job in pending:
print(job.to_dict()["scheduled_at"])Redis push-dispatch fallback window: on Redis, push-dispatch is on by
default. Enqueues are announced, delayed ones included, and the worker's
own retries and deferrals (rate limit, concurrency, circuit breaker) arm
a local timer, so jobs usually dispatch on time. The rest — a resumed
queue, a delayed job enqueued before the worker started, a deadline past
what one large batch announces or the listener can buffer — falls back to
a 2s timer rather than an immediate wake, longer than the ≤200ms polling
ceiling it replaced. push_dispatch=False restores polling.
Database performance: slow dequeue queries. Check SQLite WAL size or Postgres query plans.
Possible causes and fixes:
Batch size too small: raise batchSize and channelCapacity on
runWorker to claim and buffer more per poll — see the
execution model.
queue.runWorker({ queues: ["default"], channelCapacity: 256, batchSize: 16 });Not enough worker processes: add more worker processes against shared storage.
Capped by a limit: check you aren't capped by a per-task
maxConcurrent or rate limit.
Redis push-dispatch fallback window: on Redis, push-dispatch is on by
default. Enqueues are announced, delayed ones included, and the worker's
own retries and deferrals (rate limit, concurrency, circuit breaker) arm
a local timer, so jobs usually dispatch on time. The rest — a resumed
queue, a delayed job enqueued before the worker started, a deadline past
what one large batch announces or the listener can buffer — falls back to
a 2s timer rather than an immediate wake, longer than the ≤200ms polling
ceiling it replaced. pushDispatch: false restores polling.
Database performance: slow dequeue queries. Check SQLite WAL size or Postgres query plans.
Possible causes and fixes:
Not enough concurrency: raise .concurrency(n), or let
.autoscale(AutoscaleOptions.of(min, max)) grow the pool with queue
depth — see Execution Models.
Batch size too small: raise .batchSize(n) and .channelCapacity(n)
on the worker builder — see Batching.
Not enough worker processes: add more worker processes against shared storage.
A producer-side gate is deferring the enqueue: the Java SDK throttles
on the producer side — a queue.gate(...) (including the built-in
Recipes) can defer a job's creation into the future, which looks like
latency but is actually delayed enqueue. Check for an active gate on the
task; see Rate Limiting.
Redis push-dispatch fallback window: on Redis, push-dispatch is on by
default. Enqueues are announced, delayed ones included, and the worker's
own retries and deferrals (rate limit, concurrency, circuit breaker) arm
a local timer, so jobs usually dispatch on time. The rest — a resumed
queue, a delayed job enqueued before the worker started, a deadline past
what one large batch announces or the listener can buffer — falls back to
a 2s timer rather than an immediate wake, longer than the ≤200ms polling
ceiling it replaced. pushDispatch(false) restores polling.
Database performance: slow dequeue queries. Check SQLite WAL size or Postgres query plans.
Possible causes and fixes:
Batch size too small: unset, SchedulerConfig::batch_size defaults to
8 on Redis and 1 — one job per round trip — on SQLite/Postgres. Set it
explicitly, and set max_in_flight to the worker's own parallelism so a
deep backlog isn't claimed by one process and starved from its peers.
use flexiq::SchedulerConfig;
let worker = queue
.worker()
.register::<process_data>()
.num_workers(8)
.scheduler_config(SchedulerConfig {
batch_size: Some(16),
max_in_flight: Some(8),
..SchedulerConfig::default()
})
.spawn()?;Scheduler poll interval too high: poll_interval defaults to 50ms, so a
job can wait that long before being picked up. Lower it for latency, at the
cost of CPU and database load.
Not enough slots: every body is running and num_workers is the
ceiling. Raise it, or add worker processes against shared storage.
Capped by a limit: check the task isn't held by a rate_limit,
max_concurrent or max_in_flight_per_task on its #[flexiq::task]
attribute — a rate-limited job is rescheduled into the future rather than
run.
Redis push-dispatch fallback window: on Redis, push-dispatch is on by
default. Enqueues are announced, delayed ones included, and the worker's
own retries and deferrals (rate limit, concurrency, circuit breaker) arm
a local timer, so jobs usually dispatch on time. The rest — a resumed
queue, a delayed job enqueued before the worker started, a deadline past
what one large batch announces or the listener can buffer — falls back to
a 2s timer rather than an immediate wake, longer than the ≤200ms polling
ceiling it replaced. .push_dispatch(false) on the worker builder
restores polling.
Database performance: slow dequeue queries. Check SQLite WAL size or Postgres query plans.
Symptom: worker process memory climbs over time.
Causes:
Large result payloads: task return values are stored in the database but also held in the scheduler's result buffer briefly. If tasks return large objects (images, dataframes), memory spikes.
Fix: return a reference (file path, object key) instead of the data itself.
# Bad — large result stored in memory and DB
@queue.task()
def process_image(path: str) -> bytes:
return open(path, "rb").read()
# Good — return a path
@queue.task()
def process_image(path: str) -> str:
out = path + ".processed"
# ... write output to out ...
return outAccumulated job records: without result_ttl, the database grows
unbounded. See Database growing too large
above.
Resource leaks in tasks: a task opens a file or connection and never closes it. Use context managers.
Causes:
worker_threads worker and never closes it. Each leaked handle
accumulates for the life of the process — always release what you open.Causes:
Causes:
Job::result hands it back as an owned Vec<u8>.
Returning large buffers repeatedly is allocation churn on the way in and on
the way out — return a reference (file path, object key) instead of the data
itself.static cache or pool
reached from a body lives for the process — bound it, or it grows for as
long as the worker runs.Rust has no garbage collector, so steady growth here is a leak you can name rather than pressure you wait out — one of the three above, or a container the body appends to and never trims.
Symptom: a periodic task fires more than once per interval, or appears to run on two workers simultaneously.
Behavior: this is safe by design. Each worker's scheduler runs its own maintenance loop and checks for due periodic tasks independently — when one is due, every scheduler that notices tries to enqueue it, but a per-tick dedup key ensures only one enqueue actually creates a job. The rest resolve to that same job's existing id instead of inserting a duplicate.
If you see two completed jobs for the same periodic task in the same interval, check for duplicates:
jobs = queue.list_jobs(status="complete", task_name="daily_report", limit=50)
for j in jobs:
print(j.to_dict()["completed_at"])const jobs = await queue.listJobs({ status: "complete", task: "dailyReport", limit: 50 });
for (const job of jobs) {
console.log(job.completedAt);
}List<Job> jobs = flexiq.listJobs(
JobFilter.builder().status(JobStatus.COMPLETE).task("daily_report").limit(50).build());
for (Job job : jobs) {
System.out.println(job.completedAt);
}use flexiq::{JobStatus, Storage};
// (status, queue, task_name, limit, offset, namespace)
let jobs = queue.storage().list_jobs(
Some(JobStatus::Complete as i32),
None,
Some("daily_report"),
50,
0,
None,
)?;
for job in jobs {
println!("{:?}", job.completed_at);
}If you're genuinely seeing duplicate execution, ensure all workers use the same storage (same SQLite file path, same Postgres DSN, or same Redis URL).
A named codec must be registered under the same name on both the producer and the worker — a task opts into it by name, and if the worker's registry doesn't have that name, the job fails.
from flexiq import Queue
from flexiq.codecs import GzipCodec
# Both the producer and the worker construct their Queue with the same
# codec registered under the same name.
queue = Queue(db_path="tasks.db", codecs={"gz": GzipCodec()})
@queue.task(codecs=["gz"])
def process_large_payload(data: bytes):
...import { Queue, GzipCodec } from "@byteveda/flexiq";
// Both the producer and the worker construct their Queue with the same
// codec registered under the same name.
const queue = new Queue({ dbPath: "tasks.db", codecs: { gz: new GzipCodec() } });
queue.task("processLargePayload", (data: Buffer) => handle(data), { codecs: ["gz"] });import org.byteveda.flexiq.serialization.GzipCodec;
// Both the producer and the worker build their FlexiQ client with the
// same codec registered under the same name.
FlexiQ flexiq = FlexiQ.builder()
.sqlite("tasks.db")
.codec("gz", new GzipCodec())
.open();
Task<byte[]> PROCESS_LARGE_PAYLOAD = Task.of("process_large_payload", byte[].class)
.codecs("gz");The Rust SDK has no codec registry and no pluggable serializer — a payload is the wire CBOR envelope, always, so there is no name for the two sides to disagree on. What can still go wrong is the envelope itself, and each shape has its own error:
unsupported payload codec: tag 0x03A producer wrote a codec tag this shell does not read. Nothing to register — the job was encoded by something that does not speak the plain envelope.
call carries 2 keyword argument(s); a Rust task takes positional arguments only,
so passing them would drop them silentlyA producer sent keyword arguments. Rust has nothing to bind them to, so the call is refused rather than run with them dropped — re-enqueue the call with those values as positional arguments.
task arguments do not match the handler: ...The positional arguments did not deserialize into the function's parameters: wrong count, or a type the parameter cannot hold.
SerializationError: no codec registered named 'gz' (at
dispatch time) or ValueError: no codec registered named 'gz' (at
enqueue time).SerializationError: no codec registered named "gz".no codec registered named '...' — the task was enqueued with named
codecs; register the same names with FlexiQ.builder().codec(name, codec)
on the worker.Class payloads; use JsonSerializer/MsgpackSerializer (or a
non-generic payload type) for TypeReference payloads.There is no serializer to mismatch and no codec chain to register: the payload codec is the wire CBOR, fixed. The decode failures you can actually get are these, all fatal on the worker rather than retryable:
unsupported payload codec: tag 0x.. — the envelope's first byte is not
TAG_CBOR. The producer wrote a payload by hand instead of through
<task>::call(..).empty payload: no codec tag — a zero-length payload.malformed call envelope: .. — the bytes are CBOR but not the
[args, kwargs] call shape.task arguments do not match the handler: .. — wrong count, or a type
the parameter cannot hold. Argument order is positional and declaration
order is preserved, so this is a real signature mismatch.call carries N keyword argument(s) — a producer sent kwargs. A Rust
task is positional-only, and the call is refused by count rather than
silently dropping them.ModuleNotFoundError: No module named 'flexiq._flexiq'
The compiled Rust extension isn't present for this platform or Python
version. Reinstall from a wheel that matches both
(pip install --force-reinstall flexiq), or rebuild it locally with
uv run maturin develop if you're working from source.
Cannot find module '.../native/index.js'
The native addon isn't built. Run pnpm build:native. Workflows and mesh
also require the addon to be built with their cargo features (included
in build:native) — a "feature not enabled" error at queue.workflows
means the addon was built without them.
UnsatisfiedLinkError: no bundled native library for platform '...'
The jar has no native binary for this OS/architecture. Build the native
library for the platform and point -Dflexiq.native.lib=/path/to/library
at it. On hardened hosts where /tmp is mounted noexec, extraction
succeeds but loading fails — set
-Dflexiq.native.workdir=/path/on/exec/volume instead.
There is nothing to load. The engine is a crate compiled into your binary, so
this whole class of failure lands at build time instead: a flexiq::workflows
or flexiq::mesh that won't resolve is a cargo feature that is off, not a
library that failed to load.
flexiq = { version = "2", features = ["workflows"] }postgres, redis, workflows and mesh are all opt-in this way, and cargo
features are additive — turning one on never takes another away. SQLite is
statically linked either way, which is also why a runtime image needs no
database client library installed.
Inspect failures with queue.job_errors(id) and queue.dead_letters();
see job management.
Inspect failures with await queue.getJobErrors(id) and
await queue.deadLetters(); see
job management.
Inspect failures with flexiq.jobErrors(id) and
flexiq.listDead(50, 0); see
job management.
Neither listing is on FlexiQ — reach them through the backend, with
use flexiq::Storage; in scope:
queue.storage().get_job_errors(&job_id, None) and
queue.storage().list_dead(50, 0, None). The trailing None is the
namespace.
A failed job's error is the contract's JSON rather than a bare message, so a
reader in any shell gets the same shape:
{ "errtype": "TaskError", "message": "card declined", "traceback": [] }errtype is always "TaskError" and traceback is always [] — Rust has no
exception type to name and no stack to attach, and [] is the contract's
no-frames case. The message is whatever
TaskError::retryable or TaskError::fatal was given, and which of the two the
body chose is what decided whether the job retried or dead-lettered on the spot.