Concepts
Task, job, queue, worker, result, namespace, and the execution model — the seven words the rest of the docs assume you know.
Task, job, queue, worker, result, namespace, and the execution model — the seven words the rest of the docs assume you know.
The quickstart had you run four things without naming them. This page names them. Seven words cover the whole core: queue, task, job, worker, result, namespace, and the execution model that ties them together. Everything else in these docs is built out of these.
No prior task-queue experience is assumed. If you have used one before, the short version is: there is no broker, and a job is a database row.
Your application code hands off work and carries on. A separate process picks the work up, runs it, and writes down what happened.
Nothing in the middle is a message on a wire. Every arrow is a write to, or a read from, one database.
A queue is a handle to one store — an SQLite file, a Postgres schema, or a Redis prefix. You register tasks on it, enqueue jobs through it, run workers from it, and inspect state with it.
from flexiq import Queue
queue = Queue(db_path="tasks.db")import { Queue } from "@byteveda/flexiq";
const queue = new Queue({ dbPath: "flexiq.db" });import org.byteveda.flexiq.FlexiQ;
FlexiQ flexiq = FlexiQ.builder().sqlite("flexiq.db").open();use flexiq::FlexiQ;
let queue = FlexiQ::open("flexiq.db")?;That is the whole setup. Most task queues need a broker here — a separate server, usually Redis or RabbitMQ, whose job is to hold pending work and hand it out — plus a result backend to store return values. flexiq has neither. Pending work and finished results are rows in the same database, so there is no extra service to install, secure, or keep running.
Any number of processes pointed at the same file or DSN share one logical queue. That is how a web process and a worker process talk to each other: not over a socket, but by both writing to the same store.
Confusingly, "queue" also means a lane within that store — default,
emails, reports. One store holds many named lanes, and a worker chooses
which lanes to drain. See Queues.
When these docs say "the queue" without qualification, they mean the handle.
A task is a named function you register on the queue. The name is the contract: producers enqueue by name and never need the function body, so the process that asks for the work does not have to be able to do it.
@queue.task()
def send_welcome(user_id: int) -> str:
return f"welcomed {user_id}"queue.task("send_welcome", (userId: number) => `welcomed ${userId}`);// The descriptor is the name plus the payload type; the handler is registered
// on the worker, so a producer only ever needs this.
Task<Long> SEND_WELCOME = Task.of("send_welcome", Long.class);// The task's name is the function's name, unless `name = "..."` says otherwise.
#[flexiq::task]
fn send_welcome(user_id: i64) -> flexiq::Outcome<String> {
Ok(format!("welcomed {user_id}"))
}Per-task settings — retries, timeout, concurrency cap, rate limit — are attached here, once, rather than repeated at every call site. Tasks covers the full set.
Enqueuing a task does not run it. It writes a job: one row holding the task name, the arguments, a priority, a status, and eventually a result. You get back an id immediately.
A task is the recipe; a job is one request to cook it — one that may take several attempts. One task produces thousands of jobs.
Jobs move through a small state machine. The engine claims each one atomically, so the same job never runs twice at the same time:
A job that raises is retried with backoff — each wait longer than the last — until its retry budget runs out. Then it lands in the dead-letter queue: a holding area for jobs that failed for good, which you can inspect and replay rather than lose. Dead letters covers it.
Enqueuing always succeeds as long as the database accepts the write. If no
worker is running, jobs simply accumulate in pending. A queue that looks
stuck is usually a queue nobody is draining.
A worker is a process that polls the store, claims due jobs, runs the matching task, and writes the outcome back. It is the only place your task code executes.
flexiq worker --app tasks:queueflexiq run ./app.js --queues defaultWorker worker = flexiq.worker()
.handle(SEND_WELCOME, userId -> "welcomed " + userId)
.start();let worker = queue.worker().register::<send_welcome>().spawn()?;
// ... later: worker.shutdown()?; — drains, then unregisters.Workers are interchangeable and disposable. Run one, or twenty across five machines — they coordinate through the store, not with each other. A worker that dies mid-job does not lose it: the job's claim expires and another worker picks it up. Workers covers pools, concurrency and graceful shutdown.
A result is what the task returned, serialized and written back to the same store. Because it is a row and not a callback, any process holding the store can read it, minutes or days later.
job = send_welcome.delay(42)
print(job.result(timeout=30)) # "welcomed 42"const id = queue.enqueue("send_welcome", [42]);
console.log(await queue.result(id)); // "welcomed 42"String id = flexiq.enqueue(SEND_WELCOME, 42L);
flexiq.awaitJob(id, Duration.ofSeconds(30));
String value = flexiq.getResult(id, String.class).orElseThrow();let enqueued = queue.enqueue(send_welcome::call(42))?;
// Any process holding the store can read the row back, minutes or days later.
let job = queue.get_job(&enqueued.id)?.expect("the job exists");
println!("{:?}", job.status);
println!("{} result bytes", job.result.map_or(0, |bytes| bytes.len()));job.result is the archived return value as Option<Vec<u8>>: the Rust SDK
does not wrap a typed result read, and nothing blocks until a job finishes —
poll get_job until status is terminal.
Waiting on a result blocks the caller, which gives back the latency you enqueued to avoid — so it belongs in tests, scripts and CLI tools, not in a request handler. In a web app, enqueue and return; let the job report its own outcome. How values are encoded is the serializer's business.
A namespace scopes everything one deployment can see. Set it on the queue and every worker, dashboard and admin call inherits it — listings, totals, recovery and job ids all stop at the boundary.
Queue(namespace="tenant-a")new Queue({ namespace: "tenant-a" })FlexiQ.builder().namespace("tenant-a")FlexiQ::open("flexiq.db")?.with_namespace("tenant-a")
Leaving it unset — the default — addresses every namespace, which is what a single-tenant deployment wants. You can ignore namespaces entirely until you are running more than one logical system out of one database.
A namespace confines a correctly-configured process; it does not authenticate one. Anything holding the database credentials can open an unscoped queue and read every namespace. See Deployment.
Two properties explain most of flexiq's behaviour, and both follow from jobs being rows.
Execution is at-least-once. A job is claimed with a lease. If the worker holding it dies, the lease expires and the job becomes claimable again — which is what makes crashes survivable, and also means a job body can run more than once. Anything with an external effect (charging a card, sending mail) should be safe to repeat; idempotency is how, and durable steps is how to keep the parts that already succeeded.
Scheduling is Rust; your code is not. Polling, claiming, retry timing, rate limits, concurrency caps and cleanup all run in the shared engine — identical under every SDK, and still running while your task body executes. Your language runtime only ever runs the task body itself. Execution model has the detail for PythonNode.jsJavaRust.
You have the vocabulary. Next you use all of it at once, on something bigger than one function: Build a real app — a signup pipeline with two queues and a worker draining both.
For how the engine actually implements the state machine above — claims, leases, recovery, storage layout — read Job lifecycle when you want it. It is not required to finish this track.