Deployment
Process model, storage location, systemd/Docker, backups, monitoring, and sizing in production.
Process model, storage location, systemd/Docker, backups, monitoring, and sizing in production.
This guide covers running flexiq in production.
Unlike Celery (which needs Redis or RabbitMQ as a message broker), flexiq's scheduler lives inside the worker process and reads directly from SQLite, Postgres, or Redis. There's no separate broker process to run, monitor, or fail over — one less moving part in production.
Producers and workers are separate processes that share storage. A typical deploy has a web/API process that enqueues jobs, and one or more worker processes that claim and run them:
# myapp.py
from flexiq import Queue
queue = Queue(db_path="/var/lib/myapp/flexiq.db")
@queue.task()
def send_email(to: str, subject: str):
...flexiq worker --app myapp:queue --queues default,emails// app.ts — the module `flexiq run` loads
import { Queue } from "@byteveda/flexiq";
export const queue = new Queue({ dbPath: "/var/lib/myapp/flexiq.db" });
queue.task("sendEmail", (to: string, subject: string) => {
// ...
});flexiq run ./app.js --queues default,emailstry (FlexiQ flexiq = FlexiQ.builder().sqlite("/var/lib/myapp/flexiq.db").open();
Worker worker = flexiq.worker()
.handle(sendEmail, handlers::sendEmail)
.queues("default", "emails")
.concurrency(8) // fixed pool; 0 (default) = cached pool
.start()) {
Runtime.getRuntime().addShutdownHook(new Thread(worker::close));
worker.awaitShutdown();
}A worker is a binary you build, not a command run over your app — there is no
flexiq CLI to point at a module:
// src/bin/worker.rs
use flexiq::FlexiQ;
#[flexiq::task(queue = "emails")]
fn send_email(to: String, subject: String) -> flexiq::Outcome<()> {
let _ = (to, subject);
Ok(())
}
fn main() -> flexiq::Result<()> {
let queue = FlexiQ::open("/var/lib/myapp/flexiq.db")?;
let worker = queue
.worker()
.register::<send_email>()
.queues(["default", "emails"])
.num_workers(8)
.spawn()?;
// Nothing here installs a signal handler — the process is yours, so the
// wiring is too. `wait_for_shutdown` is written out under
// [Running the worker as a service](#running-the-worker-as-a-service).
wait_for_shutdown();
// Stops dispatch, drains in-flight work, unregisters the worker.
worker.shutdown()
}The producer opens the same path and enqueues against the same declaration:
let queue = FlexiQ::open("/var/lib/myapp/flexiq.db")?;
queue.enqueue(send_email::call("a@example.com".into(), "Welcome".into()))?;With SQLite, every producer and worker must share the same file (same host or volume). With Postgres or Redis, they can run on separate machines.
See Postgres deployment below.
See Backends.
See Backends.
See Backends. Postgres and Redis are cargo features, so the backend a build can open is decided at compile time, not at startup.
Choose a persistent, backed-up location for the database:
queue = Queue(db_path="/var/lib/myapp/flexiq.db")const queue = new Queue({ dbPath: "/var/lib/myapp/flexiq.db" });FlexiQ flexiq = FlexiQ.builder().sqlite("/var/lib/myapp/flexiq.db").open();let queue = flexiq::FlexiQ::open("/var/lib/myapp/flexiq.db")?;SQLite runs in WAL (write-ahead logging) mode by default — see Backup and restore for what that costs a backup — which appends writes to a separate log file instead of the main database file, letting many readers proceed concurrently with the one writer.
Best practices:
flexiq.db-wal), and shared memory file (flexiq.db-shm) must all be on the same filesystemCreate /etc/systemd/system/flexiq-worker.service:
[Unit]
Description=flexiq worker
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/.venv/bin/flexiq worker --app myapp:queue
Restart=always
RestartSec=5
# Graceful shutdown — flexiq handles SIGINT
KillSignal=SIGINT
TimeoutStopSec=35
# Environment
Environment=PYTHONPATH=/opt/myapp
[Install]
WantedBy=multi-user.targetSet TimeoutStopSec to slightly longer than your longest task timeout
(flexiq worker --drain-timeout defaults to 30s). This gives in-flight
tasks time to complete before systemd force-kills the process.
Create /etc/systemd/system/flexiq-worker.service:
[Unit]
Description=flexiq worker
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/node_modules/.bin/flexiq run ./app.js --queues default,emails
Restart=always
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.targetflexiq run installs its own SIGINT/SIGTERM handlers, so systemd's
default KillSignal=SIGTERM works without overriding it.
On a stop signal, flexiq run calls worker.stop(), waits a fixed
200ms, then force-exits the process — it doesn't wait for long-running
handlers to actually finish. That's enough for typical short tasks, but if
your handlers can run for seconds, write your own entrypoint that calls
queue.runWorker() / worker.stop() directly and waits as long as you need
(e.g. until your own in-flight counter hits zero) before exiting.
There's no worker CLI subcommand — workers are code. Wire the shutdown hook shown in Process model above, then run the jar as a systemd service:
[Unit]
Description=flexiq worker
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java --enable-native-access=ALL-UNNAMED -jar /opt/myapp/app.jar
Restart=always
RestartSec=5
TimeoutStopSec=65
[Install]
WantedBy=multi-user.targetworker.close() (wired to the shutdown hook) drains in-flight handlers for up
to 30 seconds, then interrupts and waits 30 more — set TimeoutStopSec a
little past that so systemd doesn't force-kill first.
There's no worker CLI subcommand — a worker is your compiled binary, so the unit
runs it directly. No interpreter, no runtime to install, nothing to put on
PATH but the executable:
[Unit]
Description=flexiq worker
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/worker
Restart=always
RestartSec=5
# SIGTERM is systemd's default, and what the handler below waits for.
TimeoutStopSec=65
Environment=RUST_LOG=info
[Install]
WantedBy=multi-user.targetwait_for_shutdown from Process model is where that signal
lands. Nothing in the SDK installs a handler, so this is the part you write:
/// Block until SIGINT or SIGTERM. SIGTERM is what systemd and every container
/// runtime send, so a handler that catches only SIGINT ends every deploy in
/// SIGKILL with jobs mid-flight.
fn wait_for_shutdown() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime to wait on");
runtime.block_on(async {
let mut terminate =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("a SIGTERM listener");
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = terminate.recv() => {}
}
});
}That needs tokio = { version = "1", features = ["rt", "macros", "signal"] }
beside flexiq — the SDK depends on tokio, but does not re-export it.
worker.shutdown() then blocks until every worker thread has exited and the
worker row is unregistered, so set TimeoutStopSec past your longest task
timeout: systemd force-kills at that deadline regardless.
sudo systemctl daemon-reload
sudo systemctl enable flexiq-worker
sudo systemctl start flexiq-worker
# Check logs
journalctl -u flexiq-worker -fFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Store the database in a volume
VOLUME /data
CMD ["flexiq", "worker", "--app", "myapp:queue"]The flexiq CLI and the Queue constructor do not read a FLEXIQ_DB_PATH
environment variable — that env var name is only recognized by the Flask and
Django integrations. If your myapp:queue module builds the Queue
directly, read the path yourself (and keep the ENV FLEXIQ_DB_PATH=...
line in the Dockerfile above), as shown below.
# myapp.py
import os
from flexiq import Queue
queue = Queue(db_path=os.environ.get("FLEXIQ_DB_PATH", "/data/flexiq.db"))FROM node:20-slim
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod
COPY . .
# Store the database in a volume
VOLUME /data
CMD ["node_modules/.bin/flexiq", "run", "./app.js"]A prebuilt native addon ships for both glibc and musl Linux, so node:20-slim
and node:20-alpine both work with no extra build step — no Python or Rust
toolchain required in the image.
Illustrative — shadowJar assumes the Gradle Shadow plugin builds a fat jar
with your app's main class as the entry point; swap in whatever packaging
task your build actually uses:
FROM gradle:8-jdk21 AS build
WORKDIR /src
COPY . .
RUN ./gradlew --no-daemon shadowJar
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /src/build/libs/app.jar app.jar
VOLUME /data
ENTRYPOINT ["java", "--enable-native-access=ALL-UNNAMED", "-jar", "app.jar"]The jar extracts the native engine to a per-user directory on first use.
Standard glibc-based images (eclipse-temurin, debian, ubuntu) work out
of the box. On a host where /tmp is noexec, set
-Dflexiq.native.workdir=/path to redirect extraction; set
-Dflexiq.native.lib=/path/to/library to skip extraction and load an
explicit binary (e.g. one built for musl).
On JDK 22+, the hot byte operations automatically use a Java FFM (Panama) fast
path packaged as a multi-release-jar overlay; older JDKs transparently fall
back to JNI — same API either way. FFM calls are "restricted native access":
--enable-native-access=ALL-UNNAMED (in the ENTRYPOINT above) grants access
and silences the warning.
Build in one stage, ship the binary in another. The runtime stage carries no toolchain, no interpreter and no package manager — the one genuine difference a compiled worker makes to a deployment:
FROM rust:1.88 AS build
WORKDIR /src
COPY . .
RUN cargo build --release --bin worker
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=build /src/target/release/worker /usr/local/bin/worker
# Store the database in a volume
VOLUME /data
ENV RUST_LOG=info
CMD ["worker"]SQLite is compiled into the binary, so the runtime stage needs nothing but a
libc. Building with the postgres feature vendors libpq and OpenSSL the same
way, so that image stays just as thin — at the cost of a longer build.
rust:1.88 is the MSRV; anything newer works. The build stage recompiles the
whole dependency tree on every source change unless you cache it — mount
/usr/local/cargo/registry and target/ with BuildKit cache mounts, or split
the manifest copy into its own layer, whichever your build already does for
other Rust images.
Read the database path from the environment yourself; nothing reads one for you:
let path = std::env::var("FLEXIQ_DB_PATH").unwrap_or_else(|_| "/data/flexiq.db".into());
let queue = flexiq::FlexiQ::open(&path)?;services:
worker:
build: .
volumes:
- flexiq-data:/data
stop_signal: SIGINT
stop_grace_period: 35s
dashboard:
build: .
command: flexiq dashboard --app myapp:queue --host 0.0.0.0
volumes:
- flexiq-data:/data
ports:
- "8080:8080"
volumes:
flexiq-data:services:
worker:
build: .
volumes:
- flexiq-data:/data
stop_signal: SIGTERM
stop_grace_period: 10s
dashboard:
build: .
command: node_modules/.bin/flexiq --db /data/flexiq.db dashboard --host 0.0.0.0
volumes:
- flexiq-data:/data
ports:
- "8787:8787"
volumes:
flexiq-data:services:
worker:
build: .
volumes:
- flexiq-data:/data
stop_signal: SIGTERM
stop_grace_period: 65s
dashboard:
build: .
command: java -cp app.jar org.byteveda.flexiq.cli.Cli --url /data/flexiq.db dashboard --port 8080
volumes:
- flexiq-data:/data
ports:
- "8080:8080"
volumes:
flexiq-data:services:
worker:
build: .
volumes:
- flexiq-data:/data
stop_signal: SIGTERM
stop_grace_period: 65s
# The SDK serves no dashboard; flexiq-server does, from the same file.
dashboard:
image: ghcr.io/byteveda/flexiq-server:2.1.0
environment:
FLEXIQ_DSN: /data/flexiq.db
FLEXIQ_DASHBOARD: 0.0.0.0:8080
FLEXIQ_DASHBOARD_AUTH: session # an off-host bind with auth off is refused
FLEXIQ_MAINTENANCE: "off" # retention belongs to the worker; this one only serves
volumes:
- flexiq-data:/data
ports:
- "8080:8080"
volumes:
flexiq-data:The worker and dashboard must access the same SQLite file. In Docker, use a named volume shared between containers. Do not use bind mounts on network storage.
flexiq handles SIGINT for graceful shutdown. Configure your container
orchestrator to send SIGINT (not SIGTERM):
stop_signal: SIGINTpreStop hook or configure STOPSIGNAL in the Dockerfile:STOPSIGNAL SIGINTFor Kubernetes, set terminationGracePeriodSeconds to match your longest
task timeout:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: worker
...flexiq run already listens for both SIGINT and SIGTERM, so the default
signal Docker and Kubernetes send (SIGTERM) works without changing
stop_signal/STOPSIGNAL. Remember the fixed ~200ms grace window called out
above — a short stop_grace_period / terminationGracePeriodSeconds (10s is
plenty) is fine for the CLI, but a custom entrypoint with its own drain logic
needs a longer one to match:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: worker
...The shutdown hook calls worker.close() on SIGTERM, Docker's and
Kubernetes' default signal — no STOPSIGNAL override needed. close() drains
for up to 30 seconds, then interrupts and waits 30 more, so give the
orchestrator enough room:
spec:
terminationGracePeriodSeconds: 65
containers:
- name: worker
...The handler shown under
Running the worker as a service waits on
SIGTERM, Docker's and Kubernetes' default signal — no stop_signal or
STOPSIGNAL override needed. What it costs is bounded by your longest task
timeout, since shutdown() waits for in-flight work:
spec:
terminationGracePeriodSeconds: 65
containers:
- name: worker
...A binary running as PID 1 in a container receives no default disposition for
SIGTERM: without the handler above, the signal is simply ignored and the
runtime SIGKILLs at the end of the grace period with jobs still claimed.
Those jobs are recovered — the stale-worker reap hands them to a survivor —
but whatever a killed attempt had already done gets done again. Install the
handler, or run the binary under an init that forwards signals.
Every deployment above runs the scheduler inside the worker, so the worker
process both imports your app and holds the database credentials. An attached
executor splits those apart: flexiq-server owns storage, claiming, retries,
the dead-letter queue and retention; your app container runs flexiq executor,
which dials the scheduler and runs whatever it is sent. The app image needs no
database credentials and no inbound port.
See Attached executors for the model, the handshake and what a task can do on one. This section is the deployment shape.
Not for disk. Two containers built from the same image share layers, so the Docker deployments above already pull a large app image once per node. What layer sharing does not fix:
An executor removes both: the process is already running and already warm, and the only new image is the scheduler's — a static binary on distroless, identical for every deployment.
If your app image is small, keep the in-process worker. The split buys nothing and costs you a second moving part.
The scheduler image is ghcr.io/byteveda/flexiq-server. Every release publishes
an exact-version tag and the mutable :latest tag
as manifest lists for linux/amd64 and linux/arm64. FlexiQ does not currently
publish moving major or minor tags, or commit-SHA tags. Pin an exact version for
controlled deployments; pin the manifest digest when byte-for-byte image
identity is required. Avoid :latest where reproducible deployment or rollback
matters.
The image runs as a non-root user on distroless and contains no shell, package
manager, or language runtime. Use logs, the health endpoints where
FLEXIQ_DASHBOARD is set — a listener-only server has no HTTP port — or
kubectl debug -it <pod> --image=<tools> --target=<container>, which runs the
tooling image as a separate ephemeral container joined to the server's process
namespace and leaves the distroless target unchanged, instead of expecting
docker exec ... sh to work.
Server & wire lists its runtime configuration.
The release workflow verifies the server version, OCI labels, and both architectures. It currently publishes no BuildKit provenance or SBOM attestation and does not sign the image, so digest pinning is the available integrity control; there is no FlexiQ-produced signature or attestation to verify yet.
The scheduler is the only new service. It is SDK-independent — the same image schedules for any app:
services:
scheduler:
image: ghcr.io/byteveda/flexiq-server:2.1.0
environment:
FLEXIQ_DSN: postgresql://flexiq:secret@postgres:5432/myapp
FLEXIQ_LISTEN: 0.0.0.0:7777
FLEXIQ_ATTACH_TOKEN: ${FLEXIQ_ATTACH_TOKEN:?generate with openssl rand -base64 32}
FLEXIQ_DASHBOARD: 0.0.0.0:8080
FLEXIQ_DASHBOARD_AUTH: session
ports:
- "8080:8080" # dashboard only — 7777 stays on the compose network
depends_on:
- postgres # the service from Postgres deployment belowThe executor service is your existing app image with a different command — no new build, no new registry pull:
services:
executor:
build: . # the app image from the Dockerfile above, unchanged
command: flexiq executor --app myapp:queue
environment:
FLEXIQ_ATTACH: scheduler:7777
FLEXIQ_ATTACH_TOKEN: ${FLEXIQ_ATTACH_TOKEN:?}
FLEXIQ_SLOTS: "4"
depends_on:
- scheduler
restart: unless-stoppedservices:
executor:
build: . # the app image from the Dockerfile above, unchanged
command: node_modules/.bin/flexiq executor ./app.js
environment:
FLEXIQ_ATTACH: scheduler:7777
FLEXIQ_ATTACH_TOKEN: ${FLEXIQ_ATTACH_TOKEN:?}
FLEXIQ_SLOTS: "4"
depends_on:
- scheduler
restart: unless-stoppedservices:
executor:
build: . # the app image from the Dockerfile above, unchanged
command: java --enable-native-access=ALL-UNNAMED -cp app.jar org.byteveda.flexiq.cli.Cli executor
environment:
FLEXIQ_ATTACH: scheduler:7777
FLEXIQ_ATTACH_TOKEN: ${FLEXIQ_ATTACH_TOKEN:?}
FLEXIQ_SLOTS: "4"
depends_on:
- scheduler
restart: unless-stoppedThe Rust SDK ships no executor command, and queue.worker() builds an
in-process worker only. Attaching means writing the executor yourself around
flexiq::ExecutorClient, supplying a WorkerDispatcher — the shell's own
dispatcher is private, so task registration does not come with it. The compose
service is then your binary, with a command of your choosing:
services:
executor:
build: . # the app image from the Dockerfile above, unchanged
command: ["executor"] # your binary, built beside the worker one
environment:
FLEXIQ_ATTACH: scheduler:7777
FLEXIQ_ATTACH_TOKEN: ${FLEXIQ_ATTACH_TOKEN:?}
FLEXIQ_SLOTS: "4"
depends_on:
- scheduler
restart: unless-stoppedThose variable names are the convention the scheduler's own documentation uses,
not something the crate reads — an executor you write reads whichever it likes
and passes the values to ExecutorConfig.
FLEXIQ_SLOTS is how many jobs the executor runs at once. There is no
FLEXIQ_DSN here — that is the point.
An executor dials once and exits when the session ends, so a scheduler restart
takes its executors down with it. restart: unless-stopped brings them back.
depends_on only orders container starts — it doesn't wait for the listener
to bind, so the first attach may fail and be retried by the same restart loop.
Run the executor as a sidecar in the app pod, from the app image. The image is already on the node for the app container, so the sidecar adds a process, not a pull.
The scheduler runs as a native sidecar next to the app and the executor attaches
over a socket on a shared emptyDir — no port, no Service, no token, and no
network hop:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
spec:
# The scheduler chmods the socket to 0660, and connect(2) needs write
# permission on it — so the two containers have to share a *group*, not a
# uid. fsGroup gives them one and makes the emptyDir setgid, so the socket
# inherits it. This is the whole access boundary for a Unix-socket attach:
# nothing outside the group can connect, and nothing else is checked.
securityContext:
fsGroup: 65532
volumes:
- name: attach
emptyDir: {}
initContainers:
# A native sidecar (Kubernetes 1.29+): starts before the app containers,
# keeps running beside them, and restarts on its own.
- name: scheduler
image: ghcr.io/byteveda/flexiq-server:2.1.0
restartPolicy: Always
env:
- name: FLEXIQ_DSN
valueFrom:
secretKeyRef: { name: flexiq, key: dsn }
- name: FLEXIQ_LISTEN
value: unix:/run/flexiq/attach.sock
volumeMounts:
- name: attach
mountPath: /run/flexiq
containers:
- name: app
image: myapp:1.4.2 # unchanged
- name: executor
image: myapp:1.4.2 # the same image — already pulled for `app`
# command: required — see below. Leave it out and this container runs
# the app image's default entrypoint, a second app that never attaches.
env:
- name: FLEXIQ_ATTACH
value: unix:/run/flexiq/attach.sock
- name: FLEXIQ_SLOTS
value: "4"
volumeMounts:
- name: attach
mountPath: /run/flexiqThe executor's command:
command: ["flexiq", "executor", "--app", "myapp:queue"]command: ["node_modules/.bin/flexiq", "executor", "./app.js"]command:
[
"java",
"--enable-native-access=ALL-UNNAMED",
"-cp",
"app.jar",
"org.byteveda.flexiq.cli.Cli",
"executor",
]Handlers come from META-INF/services, so no application main runs to
register them.
command: ["executor"]There is no executor command in the SDK, so this is a second binary in your own
image — one that dials the scheduler with flexiq::ExecutorClient instead of
opening storage with FlexiQ::open. Both binaries come out of the same
cargo build, so the image is unchanged.
This puts one scheduler in every replica, each holding its own database
connection. They coordinate through storage, so duplicate execution isn't a
risk: a job is claimed by exactly one, retention sweeps under a lease only one
holds at a time, and a dead worker's in-flight jobs are rescued by exactly one
survivor. The connection count is what to watch — set FLEXIQ_MAINTENANCE=off
on the sidecars and keep retention on a single separate replica if the sweeps
are the part you'd rather not multiply. On Kubernetes older than 1.29, drop
initContainers and run the scheduler as an ordinary container: startup
ordering is then unenforced, and the executor's restart loop settles it.
The sidecar above serves no HTTP, so there is nothing to probe — see the note after the next example for what that costs and how to get probes back.
When you'd rather hold one database connection and scale executors independently, give the scheduler its own Deployment and Service:
apiVersion: apps/v1
kind: Deployment
metadata:
name: flexiq-scheduler
spec:
replicas: 1
selector:
matchLabels:
app: flexiq-scheduler
template:
metadata:
labels:
app: flexiq-scheduler
spec:
containers:
- name: scheduler
image: ghcr.io/byteveda/flexiq-server:2.1.0
env:
- name: FLEXIQ_DSN
valueFrom:
secretKeyRef: { name: flexiq, key: dsn }
- name: FLEXIQ_LISTEN
value: 0.0.0.0:7777
- name: FLEXIQ_ATTACH_TOKEN
valueFrom:
secretKeyRef: { name: flexiq, key: attach-token }
- name: FLEXIQ_DASHBOARD
value: 0.0.0.0:8080
- name: FLEXIQ_DASHBOARD_AUTH
value: session
- name: FLEXIQ_DASHBOARD_PUBLIC_READINESS
value: "1"
ports:
- { name: attach, containerPort: 7777 }
- { name: dashboard, containerPort: 8080 }
livenessProbe:
httpGet: { path: /health, port: dashboard }
readinessProbe:
# Needs FLEXIQ_DASHBOARD_PUBLIC_READINESS above; without it this
# route 401s at a credential-less probe and the pod never goes Ready.
httpGet: { path: /readiness, port: dashboard }
---
apiVersion: v1
kind: Service
metadata:
name: flexiq-scheduler
spec:
selector:
app: flexiq-scheduler
ports:
- { name: attach, port: 7777, targetPort: attach }Executors then set FLEXIQ_ATTACH: flexiq-scheduler:7777 and read
FLEXIQ_ATTACH_TOKEN from the same Secret. Everything else about the sidecar
is unchanged. The Service publishes attach and nothing else — give the
dashboard its own Service or Ingress when you want it reachable, so the port
that dispatches code and the port people browse are never the same object.
/health is never gated, and reports only that the process is up — which
makes it a liveness probe and nothing more. /readiness is the one that
checks storage, and it is gated alongside /metrics: with
FLEXIQ_DASHBOARD_AUTH=session, or with FLEXIQ_DASHBOARD_METRICS_TOKEN
set, a bare probe gets 401 and the pod never goes Ready.
A kubelet probe carries no credential and cannot be given one — a probe header
is a literal string in the manifest, so authenticating it would mean copying
the token out of its Secret into the Deployment spec.
FLEXIQ_DASHBOARD_PUBLIC_READINESS=1 answers that one route without a
credential instead; /metrics stays gated. What it publishes to anything that
can reach the port is whether storage answers and how many workers are
registered. Leave it off and probe /health for both if that is too much.
/readiness answers ready whenever storage answers and the worker registry
can be read — a check that failed is what degrades it. The count itself is
reported but never folded into the status, so an instance whose fleet has
scaled to zero — or a producer-only one — still goes Ready; probe it for "can
this process serve the queue API", and alert on the worker count separately
for "is anything draining the queue". On an attach deployment /readiness
reports workers: none for the same reason — executors are not workers — and
still answers 200. Both routes are dashboard routes, so any probing needs
FLEXIQ_DASHBOARD set; a listener-only server has no HTTP port, and a
tcpSocket probe on the attach port is the fallback.
Both shapes above are what the chart renders, so on Kubernetes you can skip the manifests:
helm install flexiq ./deploy/helm/flexiq-server \
--set storage.dsn='postgres://flexiq:secret@postgres:5432/myapp' \
--set attach.token="$(openssl rand -base64 32)"The chart refuses to render the mistakes this page warns about — an attach
listener with no token, a guessable one, an unauthenticated dashboard — and the
error names the value to change rather than leaving you a CrashLoopBackOff to
diagnose. The Kubernetes guide covers
current local chart installation, the OCI publication boundary, role selection,
maintenance ownership across replicas, sidecar injection, and KEDA.
Adding an executor to a workload is two edits: a container and its environment. The chart's admission webhook turns both into annotations, so a workload opts in without a manifest change beyond its own metadata:
helm upgrade flexiq ./deploy/helm/flexiq-server --reset-then-reuse-values \
--set webhook.enabled=trueapiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
metadata:
annotations:
flexiq.dev/inject: "true"
flexiq.dev/attach: "flexiq-flexiq-server-attach.default.svc:7777"
flexiq.dev/slots: "4"
flexiq.dev/token-secret: "flexiq-flexiq-server-config"
flexiq.dev/token-key: "attach-token"
# The one thing the injector cannot infer: what runs an executor inside
# your image.
flexiq.dev/command: <see below>
spec:
containers:
- name: app
image: myapp:1.4.2 # untouchedflexiq.dev/command: "flexiq executor --app myapp:queue"flexiq.dev/command: "node_modules/.bin/flexiq executor ./app.js"# A JSON array, because one of these arguments would otherwise split on its
# spaces. The bare form is fine when none of them contain any.
flexiq.dev/command: '["java","--enable-native-access=ALL-UNNAMED","-cp","app.jar","org.byteveda.flexiq.cli.Cli","executor"]'flexiq.dev/command: "executor"The name of the executor binary in your image — the SDK provides none, so this
is whatever you called the one you wrote around flexiq::ExecutorClient.
The injected container reuses the pod's own image reference, so the image is
already on the node and nothing new is pulled — the same reason the sidecar in
the manifests above repeats myapp:1.4.2, now without you writing it twice. It
also inherits the app container's env and envFrom, so a handler that reads
the same configuration the app does keeps working; flexiq.dev/inherit-env: "false" turns that off.
Worth knowing before you rely on it:
kubectl error. Admitting it would produce a
deployment that looks healthy and never runs a job.flexiq-executor
container is admitted untouched — a second sidecar would silently double its
slots.failurePolicy defaults to Ignore, so an injector that is down or wedged
cannot stop pod creation across the cluster. Move it to Fail once a workload
genuinely depends on being injected, and accept that trade.unix: address needs flexiq.dev/socket-volume naming the volume that
carries the socket; the sidecar cannot reach a path it has not mounted, and the
webhook rejects the pod rather than letting it crash-loop on ENOENT.FLEXIQ_ATTACH_TLS_CA copied from the app names a file only the app container
can see. Name the Secrets instead, and the webhook mounts them — see below.For a tls:// address, two more annotations hand the sidecar its TLS material
from Secrets. The webhook mounts each one read-only under /etc/flexiq/ and
points the matching variable at it:
flexiq.dev/attach: "tls://flexiq-flexiq-server-attach.default.svc:7777"
# The CA the listener's certificate chains to → FLEXIQ_ATTACH_TLS_CA.
flexiq.dev/tls-ca-secret: "flexiq-attach-tls"
flexiq.dev/tls-ca-key: "ca.crt" # the default
# For mTLS: a kubernetes.io/tls Secret → FLEXIQ_ATTACH_TLS_CERT / _KEY.
flexiq.dev/tls-client-secret: "myapp-executor-cert"A Secret mounts only into pods in its own namespace, so copy the CA into the
workload's namespace when the two differ. Either annotation beside an address
that is not tls:// gets the pod rejected — the executor would refuse TLS
material next to a plaintext address anyway. Without tls-ca-secret, the
sidecar verifies against the web PKI roots built into the SDK.
Rotation. The executor reads its TLS files once, when it dials, and TLS
checks a certificate only at the handshake, so a live session survives the old
certificate expiring. The kubelet refreshes the mounted Secret in place (the
mounts are whole directories, never subPath). When the session ends, the
executor exits, the kubelet restarts the container, and the new attach presents
the new certificate. Nothing needs a restart for a routine renewal; to cut off a
revoked certificate now, kubectl rollout restart the workload.
The attach port dispatches code. These are requirements, not options:
FLEXIQ_LISTEN on anything but loopback
refuses to start without FLEXIQ_ATTACH_TOKEN, and a token under 16
characters is rejected. Generate it with openssl rand -base64 32.argv. No SDK offers a flag
for it — a secret in argv shows up in ps output and shell history. Mount it
from a Secret or a compose .env, not a command: line.FLEXIQ_LISTEN_TLS_CERT and FLEXIQ_LISTEN_TLS_KEY terminate TLS on this
listener directly; FLEXIQ_LISTEN_TLS_CLIENT_CA turns on mTLS beside it, as
defence in depth alongside the token rather than instead of it. Unset,
plaintext behind a sidecar proxy or a service mesh works just as well — see
The gRPC door for the shared shape these
variables take on every listener.8080 and not 7777; the Service exposes attach inside
the cluster and nothing to a LoadBalancer.0660, so the shared group is the access control — set fsGroup
explicitly rather than inheriting one image's default and hoping it matches.If the split isn't worth it for you but cold start is, the complementary fix is lazy image pulling — SOCI or eStargz, depending on your runtime. Both let a container start before the image is fully pulled, so a scale-up doesn't block on the last layer. Note the scope: it fixes the pull, not the second resident interpreter or JVM. Combining it with a conventional worker is the smaller change; an executor is the one that reclaims the RAM.
FLEXIQ_SLOTS, or run a
second replica set from the same image with the HTTP server off — you still
pay one image, and the two sets scale independently.FLEXIQ_GRPC_LISTEN binds a fourth listener beside attach, the dashboard and
the admission webhook: the network-side producer door, plus the executor door,
the JSON facade and reflection.
It has its own pages, because a network endpoint is a different operational object from the process model above — a credential to mint and rotate, a namespace it refuses to start without, TLS it can terminate itself, and its own scrape:
FlexiQ opens SQLite in WAL mode, so committed data may be in the -wal sibling
rather than in the .db file at any instant, and cp is not a backup. The safe
methods for each backend — and what a restore does to jobs that were in flight,
to durable-step memos and to revoked credentials — are on
Backup and restore.
If you're using the Postgres backend, deployment is simpler in several ways:
Switching to Postgres or Redis removes SQLite's shared-file constraint:
Switching to Postgres or Redis removes SQLite's shared-file constraint:
Switching to Postgres or Redis
removes SQLite's shared-file constraint. Both are cargo features, so this is a
build decision before it is a deployment one — cargo add flexiq --features postgres, and the same flag reaches flexiq-core:
pg_dump instead of sqlite3 .backupservices:
postgres:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: flexiq
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
worker:
build: .
environment:
FLEXIQ_BACKEND: postgres
FLEXIQ_DB_URL: postgresql://flexiq:secret@postgres:5432/myapp
depends_on:
- postgres
stop_signal: SIGINT
stop_grace_period: 35s
volumes:
pgdata:FLEXIQ_BACKEND/FLEXIQ_DB_URL above are only read automatically by the
Flask and Django integrations — if myapp:queue builds the Queue directly,
read them yourself, the same way the Dockerfile section above
does for FLEXIQ_DB_PATH.
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: flexiq
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
worker:
build: .
environment:
FLEXIQ_PG_URL: postgresql://flexiq:secret@postgres:5432/myapp
depends_on:
- postgres
stop_signal: SIGTERM
stop_grace_period: 10s
volumes:
pgdata:FLEXIQ_PG_URL above is illustrative — read it in your own entrypoint
(new Queue({ backend: "postgres", dsn: process.env.FLEXIQ_PG_URL })); the
flexiq CLI and runWorker() don't read it automatically.
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: flexiq
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
worker:
build: .
environment:
FLEXIQ_PG_URL: postgresql://flexiq:secret@postgres:5432/myapp
depends_on:
- postgres
stop_signal: SIGTERM
stop_grace_period: 65s
volumes:
pgdata:FLEXIQ_PG_URL above is illustrative — read it yourself (e.g.
FlexiQ.builder().postgres(System.getenv("FLEXIQ_PG_URL")).open()); the
builder has no automatic env-var lookup.
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: flexiq
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
worker:
build: . # built with --features postgres
environment:
FLEXIQ_PG_URL: postgresql://flexiq:secret@postgres:5432/myapp
depends_on:
- postgres
stop_signal: SIGTERM
stop_grace_period: 65s
volumes:
pgdata:FLEXIQ_PG_URL above is illustrative — nothing reads an environment variable
for you. FlexiQ::open is SQLite-only; another backend goes through
from_storage:
use flexiq::{FlexiQ, PostgresStorage, StorageBackend};
let dsn = std::env::var("FLEXIQ_PG_URL")?;
let queue = FlexiQ::from_storage(StorageBackend::Postgres(PostgresStorage::new(&dsn)?))?;from_storage is where the contract floor is checked, exactly as open
checks it — a handle built around a backend you opened yourself is held to
the same gate.
See the Postgres Backend guide for full configuration details.
See Backends for Redis, and for when to reach for Postgres over SQLite.
See Backends for Redis, and for when to reach for Postgres over SQLite.
See Backends for Redis, and for when to reach for Postgres over SQLite.
Set result_ttl to automatically purge old completed jobs:
queue = Queue(
db_path="/var/lib/myapp/flexiq.db",
result_ttl=86400, # Purge completed/dead jobs older than 24 hours
)There's no built-in TTL option — run manual cleanup on a schedule instead (a cron job, or a periodic task on the queue itself).
There's no built-in TTL option — run manual cleanup on a schedule instead (a cron job, or a periodic task on the queue itself).
Retention is a scheduler setting rather than a queue one, so the worker sweeps
for you. RetentionConfig::uniform(ms) puts one window on every table:
use flexiq::{RetentionConfig, SchedulerConfig};
let worker = queue
.worker()
.register::<send_email>()
.scheduler_config(SchedulerConfig {
retention: Some(RetentionConfig::uniform(24 * 60 * 60 * 1_000)),
..SchedulerConfig::default()
})
.spawn()?;RetentionConfig::recommended() sets a sensible window per table instead —
dead letters outlive task logs, which is usually what you want. The sweep runs
on the scheduler's cleanup_interval tick, so a producer-only process cleans
nothing: at least one worker has to be running.
# Purge completed jobs older than 7 days (older_than is in seconds)
queue.purge_completed(older_than=604800)
# Purge dead letters 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 letters 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 letters 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 letters older than 30 days
queue.storage().purge_dead(30 * 24 * 60 * 60 * 1_000)?;The database grows as jobs accumulate — roughly 1 KB per job for metadata and
small payloads, more for large arguments or results. Without cleanup, expect
steady growth; with regular purging (above) the database stays compact. You
can also periodically run VACUUM to reclaim space:
sqlite3 /var/lib/myapp/flexiq.db "VACUUM;"VACUUM rewrites the entire database and requires exclusive access. Run
it during low-traffic periods or during a maintenance window.
flexiq dashboard --app myapp:queue --host 0.0.0.0 --port 8080flexiq --db flexiq.db dashboard --host 0.0.0.0 --port 8787flexiq --url flexiq.db dashboard --port 8080# Auth is off by default, and an off-host bind with auth off is refused at
# startup — so an 0.0.0.0 dashboard names its auth mode.
FLEXIQ_DSN=/var/lib/myapp/flexiq.db \
FLEXIQ_DASHBOARD=0.0.0.0:8080 \
FLEXIQ_DASHBOARD_AUTH=session \
flexiq-serverThe dashboard serves openly by default — enable session authentication
for production with --auth (or serve_dashboard(auth_enabled=True)).
With auth enabled, bootstrap the first admin with
FLEXIQ_DASHBOARD_ADMIN_USER / FLEXIQ_DASHBOARD_ADMIN_PASSWORD, or
create one through the SPA's first-run setup. Serve it over TLS — see
Security: Dashboard for the
full auth, cookie, and role model.
Auth runs open by default — anyone who can reach the port has full
access. Enable session authentication for production with --auth (or
authEnabled: true to serveDashboard), pass a legacy bearer token
(--token, or auth: { token }) for a quick gate, or mount the dashboard
behind your own auth via the
Express or
Fastify helpers. Either way, put it
behind TLS in production.
The dashboard serves openly by default — enable session authentication
(with OAuth/SSO support) for production with --auth (or
DashboardServer.start(queue, port, true)); pass a token to
start(...)/dashboard(...) for the legacy single-credential mode
instead. With auth enabled, bootstrap the first admin with
FLEXIQ_DASHBOARD_ADMIN_USER / FLEXIQ_DASHBOARD_ADMIN_PASSWORD, or
through the SPA's first-run setup. See
Dashboard: Auth for the full model.
The SDK serves no dashboard — flexiq-server does, over the same storage, and
it is the same binary the scheduler and attach sections above use. It is
configured entirely from the environment; there are no flags.
FLEXIQ_DASHBOARD_AUTH defaults to off, so the port serves openly. Set it
to session for production and bootstrap the first admin with
FLEXIQ_DASHBOARD_ADMIN_USER / FLEXIQ_DASHBOARD_ADMIN_PASSWORD, or through
the SPA's first-run setup. Binding an unauthenticated dashboard off-host needs
FLEXIQ_ALLOW_INSECURE=1 — the server refuses rather than doing it quietly.
Serve it over TLS either way; the server does not terminate TLS for this
listener. Server & wire documents the rest of its configuration.
Every SDK's dashboard also exposes liveness/readiness probes outside the auth gate, for load balancer and orchestrator health checks:
curl http://localhost:8080/health # always public — liveness
curl http://localhost:8080/readiness # storage/worker/resource readinessPoll queue stats and export them to your monitoring system:
import time
def export_metrics():
while True:
stats = queue.stats()
# Export to Prometheus, Datadog, StatsD, etc.
gauge("flexiq.pending", stats["pending"])
gauge("flexiq.running", stats["running"])
gauge("flexiq.dead", stats["dead"])
time.sleep(15)async function exportMetrics() {
for (;;) {
const stats = await queue.stats();
// Export to Prometheus, Datadog, StatsD, etc.
gauge("flexiq.pending", stats.pending);
gauge("flexiq.running", stats.running);
gauge("flexiq.dead", stats.dead);
await new Promise((r) => setTimeout(r, 15_000));
}
}void exportMetrics() throws InterruptedException {
while (true) {
QueueStats stats = flexiq.stats();
// Export to Prometheus, Datadog, StatsD, etc.
gauge("flexiq.pending", stats.pending);
gauge("flexiq.running", stats.running);
gauge("flexiq.dead", stats.dead);
Thread.sleep(15_000);
}
}use std::time::Duration;
fn export_metrics(queue: &flexiq::FlexiQ) -> flexiq::Result<()> {
loop {
let stats = queue.stats()?;
// Export to Prometheus, Datadog, StatsD, etc.
gauge("flexiq.pending", stats.pending);
gauge("flexiq.running", stats.running);
gauge("flexiq.dead", stats.dead);
std::thread::sleep(Duration::from_secs(15));
}
}For a ready-made exporter instead of polling by hand, see Prometheus.
For a ready-made exporter instead of polling by hand, see Prometheus.
For a ready-made exporter instead of polling by hand, see Micrometer.
There is no ready-made exporter in the SDK. flexiq-server serves /metrics
beside the dashboard if you would rather scrape than poll — it reads the same
storage, so it needs no cooperation from the worker.
@queue.on_failure
def alert_on_failure(task_name, args, kwargs, error):
# Fires on every raised exception, including ones that still have
# retries left. Send to PagerDuty, Slack, email, etc.
notify(f"Task {task_name} failed: {error}")// Fires once a job exhausts its retries and dead-letters.
queue.on("job.dead", (event) => {
notify(`Task ${event.taskName} dead-lettered: ${event.error}`);
});// Fires once a job exhausts its retries and dead-letters.
flexiq.worker()
.handle(sendEmail, handlers::sendEmail)
.on(EventName.DEAD, event -> notify("Task dead-lettered: " + event.error))
.start();The Rust SDK has no event or hook surface — WorkerBuilder exposes no callback
and there is nothing to subscribe to. The nearest seam is
flexiq_core::Worker::on_outcome, which fires per settled job but means
building the worker through flexiq_core::Worker directly, supplying your own
WorkerDispatcher. Watching the dead-letter count is the option that costs
nothing:
// Alongside the metrics export above. `dead` is a count, not a stream, so
// compare it against the last one you saw — and note a purge lowers it.
let dead = queue.stats()?.dead;
if dead > last_seen {
notify(format!("{} job(s) dead-lettered", dead - last_seen));
}
last_seen = dead;on_failure fires on every attempt that raises, whether or not a retry
follows. To alert only once a job is permanently dead-lettered, use
queue.on_event(EventType.JOB_DEAD, ...) instead — see
Events.
See Events for the full set
(job.completed, job.retrying, job.dead, job.cancelled) and
Webhooks to deliver them to an
external HTTP endpoint instead of in-process.
See Events for the full set (SUCCESS,
RETRY, DEAD, CANCELLED) and
Webhooks to deliver them to an
external HTTP endpoint instead of in-process.
Events and webhooks are not wrapped either — they are storage-side machinery
the SDK exposes no surface for. For a running deployment, flexiq-server's
dashboard is the thing that already reads and renders them.
from fastapi import FastAPI
from flexiq.contrib.fastapi import FlexiQRouter
app = FastAPI()
app.include_router(FlexiQRouter(queue), prefix="/tasks")
# GET /tasks/stats returns queue health
# Use this as a health check endpoint in your load balancerFlexiQRouter has no auth of its own — mount it behind your app's existing
auth, or stick to the dashboard's public /health//readiness probes above.
import express from "express";
import { flexiqRouter } from "@byteveda/flexiq/contrib/express";
const app = express();
app.use("/tasks", flexiqRouter(queue));
// GET /tasks/stats returns queue health
// GET /tasks/health, GET /tasks/readiness are the probesflexiqRouter has no auth of its own — mount it behind your app's existing
auth, or stick to the dashboard's public /health//readiness probes above.
Without any router, checkHealth() and checkReadiness(queue) give you the
same probe payloads to serve from your own routes:
import { checkHealth, checkReadiness } from "@byteveda/flexiq";
app.get("/healthz", (_req, res) => res.json(checkHealth()));
app.get("/readyz", async (_req, res) => {
const report = await checkReadiness(queue);
res.status(report.status === "ready" ? 200 : 503).json(report);
});There's no lightweight REST-framework helper — use the dashboard's /health
and /readiness probes above (public by default; gate them with
FLEXIQ_DASHBOARD_METRICS_TOKEN), or add a route in your own web framework
that calls flexiq.stats().
There's no web-framework helper — the SDK serves no HTTP at all. Two options:
flexiq-server with FLEXIQ_DASHBOARD set and probe its /health and
/readiness routes. /readiness is gated alongside /metrics, so a
credential-less kubelet probe needs FLEXIQ_DASHBOARD_PUBLIC_READINESS=1 —
see the warning under One scheduler, over TCP.queue.stats()?. It is one storage
read, which is exactly what makes it a readiness check: if it answers,
storage answers.flexiq is designed as a single-process task queue when using SQLite. Running multiple worker processes against the same SQLite file is possible (WAL mode allows concurrent access), but:
For most single-machine workloads, one worker process with multiple threads (the default) is sufficient:
queue = Queue(
db_path="myapp.db",
workers=8, # 8 OS threads in the worker pool
)If you need distributed workers across multiple machines, use the Postgres backend which removes the single-writer constraint and supports multi-machine deployments.
There's no thread-pool size to tune — a Node worker dispatches jobs as
ordinary event-loop async invocations, bounded by channelCapacity and
per-task/per-queue concurrency
caps, not an OS thread count. Scaling out means running more worker
processes (or hosts) against the same storage — the core claims each job
for exactly one of them, so adding workers adds throughput without duplicate
execution. See Execution model.
Multiple processes against one SQLite file work (WAL mode allows concurrent
access), but only one can write at a time — heavy concurrent writers surface
as SQLite database is locked errors. Move to
Postgres or Redis once that
happens.
Size the handler thread pool with concurrency(n), or let
autoscale resize it between a min and
max as queue depth changes:
flexiq.worker()
.handle(sendEmail, handlers::sendEmail)
.concurrency(8) // fixed pool; near the core count for CPU-bound work
.start();Scaling out means running more worker processes (or hosts) against shared storage. Multiple processes against one SQLite file work (WAL mode allows concurrent access), but only one can write at a time — move to Postgres or Redis for heavy concurrent writers or true multi-machine deployment.
num_workers(n) sizes the pool inside one process — the number of task bodies
that may run at once. Bodies run on spawn_blocking threads, so a blocking
body costs one slot and nothing more; near the core count is the right shape
for CPU-bound work, higher for I/O-bound:
let worker = queue
.worker()
.register::<send_email>()
.num_workers(8) // default is 4
.spawn()?;Scaling out means running more worker processes (or hosts) against shared
storage — the core claims each job for exactly one of them, so adding workers
adds throughput without duplicate execution. Give each one a worker_id you
choose if you want them identifiable in the registry; otherwise one is
generated.
Multiple processes against one SQLite file work (WAL mode allows concurrent access), but only one can write at a time — move to Postgres or Redis for heavy concurrent writers or true multi-machine deployment.
flexiq uses SQLite as its default storage backend. Understanding its limitations helps you plan for production.
Single-writer constraint. SQLite allows only one write transaction at a time. WAL mode lets reads proceed concurrently with writes, but all writes are serialized — this is the primary throughput ceiling, regardless of SDK.
Expected throughput. On modern hardware with an SSD, expect:
Queue constructor. The pool_size argument
only takes effect on the
Postgres backendWhen to upgrade to Postgres:
flexiq's Postgres backend addresses all of these limitations while keeping the same API.
Unlike the Python SDK, the SQLite connection pool size is configurable here
too (default 8) — new Queue({ poolSize: 16 }) — though the single-writer
constraint above still caps write throughput regardless of pool size. When
lock contention or the need for multi-machine workers shows up, move to
Postgres or Redis; exact
throughput numbers depend heavily on payload size and handler duration, so
benchmark your own workload rather than relying on a generic figure.
Unlike the Python SDK, the SQLite connection pool size is configurable here
too (default 8) — .poolSize(16) on the builder — though the single-writer
constraint above still caps write throughput regardless of pool size. When
lock contention or the need for multi-machine workers shows up, move to
Postgres or Redis; exact
throughput numbers depend heavily on payload size and handler duration, so
benchmark your own workload rather than relying on a generic figure.
The SQLite connection pool is 8 by default. FlexiQ::open takes that default;
another number goes through the backend, and then through from_storage:
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))?;The single-writer constraint above still caps write throughput regardless of pool size — more connections buy concurrent reads, which is what a listing or a stats poll competing with a busy worker actually needs. When lock contention or multi-machine workers show up, move to Postgres or Redis; throughput depends heavily on payload size and body duration, so benchmark your own workload rather than relying on a generic figure.
| Throughput | Backend | Workers | Pool | Notes |
|---|---|---|---|---|
| < 100 jobs/s | SQLite | 4 | thread | Default config works fine |
| 100–1K jobs/s | SQLite | 8–16 | thread or prefork | Increase workers, monitor WAL size |
| 1K–5K jobs/s | SQLite | 16 | prefork | Prefork for CPU-bound; SQLite handles this well with WAL |
| 5K–20K jobs/s | Postgres | 16–32 | prefork | Switch to Postgres for concurrent writers |
| 20K–50K jobs/s | Postgres | 32+ | prefork | Multiple worker processes, tune pool_size |
| > 50K jobs/s | — | — | — | Consider Celery + RabbitMQ for this scale |
Pool is the worker execution pool: thread (default, one process with
multiple OS threads) or prefork (multiple worker subprocesses, true CPU
parallelism). See Prefork Workers
for when and how to switch.
These are rough guidelines for noop tasks. Real throughput depends on task duration, payload size, and I/O patterns.
There's no published throughput table for the Node SDK — the event-loop
execution model (no thread-pool sizing to tune) makes it a poor fit for the
kind of workers/pool matrix the Python SDK publishes. As a starting
point:
worker_threads and keep the event loop freechannelCapacity/batchSize and add worker processesSee Execution model and Concurrency. Benchmark your own handlers rather than assuming generic numbers transfer.
There's no published throughput table for the Java SDK — real-thread
execution and concurrency/autoscale sizing don't map onto the Python
SDK's workers/pool matrix. As a starting point:
concurrency(n) near the core countSee Execution model. Benchmark your own handlers rather than assuming generic numbers transfer.
There's no published throughput table — a compiled body's cost varies too widely for one to mean anything. As a starting point:
num_workers(n) near the core countnum_workers, and raise
SchedulerConfig::batch_size with it. It caps the jobs one dispatch round
claims, and a round never claims more than the worker pool has room for or
max_in_flight allows. Unset, it is 1 on SQLite/Postgres (one job per
claim round trip) and 8 on Redis; a value you set is used on any backend,
with 0 read as 1SchedulerConfig::max_in_flight to the pool size, so one process never
claims a backlog its peers could have sharedSee Execution model, and the benchmark for a program that measures all three rates on your own hardware.
A namespace scopes everything a deployment can see and act on. Set it once, on the queue, and every worker, dashboard and admin call inherits it:
Queue(namespace="tenant-a")new Queue({ namespace: "tenant-a" })FlexiQ.builder().namespace("tenant-a")FlexiQ::open(path)?.with_namespace("tenant-a")
The boundary holds in both directions:
Leaving the namespace unset (the default) addresses every namespace. That is what a single-tenant deployment wants, and what an operator console pointed at the whole cluster wants — but it means an unscoped process is not confined by this boundary. Scope the ones that should be.
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. Isolation between untrusted tenants needs separate databases or separate credentials, not just separate namespaces.
Runs created before the namespace column existed carry none, so a scoped process does not see them — the same trade every other pre-namespace row makes. An unscoped console still does.
By default, opening a queue applies any pending schema changes — zero-config, and the right behavior for most deployments. Where the application's database credentials do not permit DDL, or a DBA owns every migration, turn it off and apply the schema explicitly:
Queue(auto_migrate=False)new Queue({ autoMigrate: false })FlexiQ.builder().autoMigrate(false)SqliteStorage::unmigrated(path, 8)
Nothing then applies DDL until you run the migrate command, which is idempotent and safe to run on every deploy:
flexiq migrateThe Rust SDK ships no CLI of its own. In-process, the same work is one call on
the backend — queue.storage().migrate()? — returning the report described
below. There is also no auto_migrate flag on FlexiQ: gating means building
the backend with unmigrated and handing it to from_storage.
It reports the versions it applied — core and workflow tables both — plus any terminal jobs the one-time backlog sweep moved into the archive. On an already-current database it applies nothing and says so. On Redis there is no schema to apply and it reports that instead of failing.
A gated queue has no tables until the command runs, so every query fails until
then. flexiq-server honors the same switch through FLEXIQ_AUTO_MIGRATE
and refuses to start when the core schema is absent, naming the variable
rather than leaking a bare "no such table". Workflow tables are not part of
that check — a deployment that never runs workflows does not need them — so
run the command, which applies both.
A deployment outlives individual releases: during any rolling upgrade one database is read by processes at different versions, and in a polyglot setup by different SDKs too. Two things keep that safe.
Schema changes are expand-only. A new column is nullable or defaulted, an existing one keeps its meaning forever, and a reader treats a field a peer never wrote as absent rather than failing. That is why a worker registered by an older release reports no SDK version instead of a wrong one.
The storage carries a floor. Every build speaks one contract level — the revision of the shared storage and wire contract it implements. The floor is the lowest level a process may speak and still open the storage; a build below it refuses to start, naming both levels, rather than joining and misreading rows its contract never described.
queue.min_contract()queue.minContract()flexiq.minContract()flexiq::min_contract(queue.storage())
The floor starts permissive and is an operator's dial, not an automatic one — one that rose on its own would lock out the peers still mid-rollout. Raise it only once every process is upgraded:
queue.set_min_contract(2)queue.setMinContract(2)flexiq.setMinContract(2)flexiq::set_min_contract(queue.storage(), 2)
A level the calling build cannot itself speak is rejected, since that write would lock the operator out of their own storage. An attached executor has no storage of its own, so it has no floor to check — the scheduler it attaches to was already held to one.
result_ttl automates thisno built-in TTL — schedule purgeCompleted/purgeDeadno built-in TTL — schedule purgeCompleted/purgeDeadSchedulerConfig::retention automates this, on a running worker)timeout=timeoutMs.timeout(Duration...)#[flexiq::task(timeout = "5m")])SIGINTSIGINT/SIGTERM, mind the fixed 200ms CLI graceSIGTERM via a shutdown hookSIGTERM, but only if you installed a handler — nothing does it for you)FLEXIQ_ATTACH_TOKEN for any bind but loopback, and
terminate TLS — FLEXIQ_LISTEN_TLS_CERT/_TLS_KEY in process, or a proxy
in front of it