Delete Redis from your stack.

FlexiQ is a task queue with a Rust core and no message broker. Jobs, results, rate limits and cron schedules live in one SQLite file. Python, Node and Java are peers over that file — enqueue in one, run workers in another.

$ ps aux | grep -E 'redis|celery|flexiq'
redis-server *:6379
celery -A myapp beat
celery -A myapp worker
flexiq worker --app tasks:queue
tasks.pysource ↗
from flexiq import Queue

queue = Queue(db_path="tasks.db")

@queue.task(max_retries=3, rate_limit="5/s")
def send_email(to, subject, body):
    smtp.send(to, subject, body)

send_email.delay("ada@example.com", "Welcome", "…")

max_retries and rate_limit are the two things that are hard to get right. They are one argument each here because the Rust scheduler enforces them across every worker — not your try/except block, and not a per-process counter that resets on deploy.

The ledger

Four numbers, counted from your deployment.

Measured against a stock Celery install with a Redis broker, a result backend and a beat daemon — the arrangement most Python services are actually running. Nothing here is a benchmark; they are things you operate.

  • 31
    Processes to runRedis, a worker and a beat daemon collapse into one worker.
  • 10
    Network services to secureNo port to bind, firewall, authenticate or upgrade.
  • 21
    Places job state livesBroker and result backend become one file with one transaction.
  • 10
    Add-ons for a dashboardFlower is a separate install. FlexiQ ships its dashboard in the box.

The work does not go away — it moves into the Rust core and the file next to your app. What goes away is the part you page someone about at 3am.

The lab

Everything works until it doesn’t. Break this one.

The happy path is not the part you are unsure about. Kill a worker mid-job, fail four calls in five, flood a rate limit, exhaust a retry budget — and watch what the queue does about it. Pick one.

  • 0Succeeded
  • 0Retried
  • 0Dead-lettered
  • 0Rate limited
  • 0Dropped

What happens to the job it was holding?

It goes back on the queue. The claim is a lease in the database, not state in the process, so when the lease expires the scheduler re-dispatches the job to somebody else. Nothing is lost and nothing is run twice.

Read how it works →
tasks.py — the configuration that does it
# The worker holds a lease, not the job.
# Kill it and the scheduler reclaims the work.
queue = Queue(db_path="tasks.db", workers=6)

Events

newest first
  • Nothing has happened yet.

This is a simulation of FlexiQ’s documented behaviour running in your browser — the backoff curve, the token bucket and the dispatch order are ported from the core, and the numbers above are real outputs of that model. It is not the Rust core compiled to WASM, and it is not a benchmark. The architecture is where the real thing is written down.

One store, three runtimes

Enqueue in Node. Run the worker in Python. Nobody rewrites the model pipeline in TypeScript.

Python, Node and Java bind to the same Rust core and the same table layout, so which runtime enqueues a job and which one executes it are separate decisions. Your API stays where your API is good; the work goes where the libraries are.

Your API enqueues

server.ts
import { Queue } from "@byteveda/flexiq";

const queue = new Queue({ dbPath: "tasks.db" });

app.post("/reports", async (req, res) => {
  const id = queue.enqueue("build_report", [req.body.orgId]);
  res.json({ jobId: id });
});

Your workers run

worker.py
from flexiq import Queue

queue = Queue(db_path="tasks.db")

@queue.task(name="build_report", max_retries=3)
def build_report(org_id):
    return pandas_pipeline(org_id)

# $ flexiq worker --app worker:queue
tasks.dbjobs · results · schedules · rate limit state

The storage layout is documented rather than internal, because two runtimes sharing a table is only a feature if the shape of that table is a promise.

Read the source

The retry curve, quoted rather than described.

Retries use Full Jitter, and the cap grows exponentially. Retry behaviour is where task queues quietly differ, and where a landing page is cheapest to lie on — so here is the function itself. Full Jitter draws the whole delay from [0, cap] rather than adding a fixed wobble to a fixed backoff, and that is what actually spreads a stampede: the spread grows with the cap instead of staying one jitter wide.

What you write

tasks.py
@queue.task(
    max_retries=5,
    retry_backoff=1.0,
    max_retry_delay=300,
)
def charge_card(order_id):
    ...

What runs

crates/flexiq-core/src/resilience/retry.rsopen ↗
pub fn next_retry_at(&self, retry_count: i32) -> i64 {
    if let Some(ref delays) = self.custom_delays_ms {
        if let Some(&custom) = delays.get(retry_count as usize) {
            return now_millis() + custom;
        }
    }

    let cap = self
        .base_delay_ms
        .saturating_mul(1i64 << retry_count.min(30))
        .min(self.max_delay_ms);

    now_millis() + full_jitter(cap)
}

MIT licensed, and the failure model is written down — what happens when a worker is killed, when storage is unreachable, and when a task blows its soft timeout — rather than left to be discovered in production.

Get started

Five minutes from install to your first job.

Define a task, enqueue it, watch a worker run it — in the language you already use. No broker, no second daemon, no configuration file.

$pip install flexiq
$pnpm add @byteveda/flexiq
implementation("org.byteveda:flexiq:1.1.0")