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();
}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.
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();SQLite runs in WAL (write-ahead logging) mode by default — see WAL mode and backups below — 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.
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.
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: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
...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 is the only new service. It is SDK-independent — the same image schedules for any app:
services:
scheduler:
image: ghcr.io/byteveda/flexiq-server:0.21.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-stoppedFLEXIQ_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:0.21.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.
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:0.21.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.
On an attach deployment /readiness reports workers: none — 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. See the chart's README for every value.
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 --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"]'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.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-server does not terminate
TLS itself, and setting FLEXIQ_LISTEN_TLS_CERT/FLEXIQ_LISTEN_TLS_KEY
fails at startup rather than being quietly ignored — a deployment can't come up
believing it is encrypted when it isn't.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 uses SQLite in WAL (write-ahead logging) mode for concurrent read/write access. This affects how you back up the database.
Do NOT simply copy the .db file while the worker is running — you may
get a corrupted backup if the WAL hasn't been checkpointed.
Safe backup methods, both safe while the worker is running:
# Option 1: sqlite3 .backup command (safe, online)
sqlite3 /var/lib/myapp/flexiq.db ".backup /backups/flexiq-$(date +%Y%m%d).db"
# Option 2: SQLite VACUUM INTO command
sqlite3 /var/lib/myapp/flexiq.db "VACUUM INTO '/backups/flexiq-$(date +%Y%m%d).db';"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:
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.
# Dump the flexiq schema
pg_dump -h localhost -U flexiq -d myapp -n flexiq > backup.sql
# Restore
psql -h localhost -U flexiq -d myapp < backup.sqlSee 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.
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).
# 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());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 8080The 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.
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);
}
}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.
@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();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.
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().
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.
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.
| 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.
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")
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)
Nothing then applies DDL until you run the migrate command, which is idempotent and safe to run on every deploy:
flexiq migrateIt 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()
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)
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/purgeDead)timeout=timeoutMs.timeout(Duration...))SIGINTSIGINT/SIGTERM, mind the fixed 200ms CLI graceSIGTERM via a shutdown hook)sqlite3 .backup (not file copy), or pg_dump for PostgresFLEXIQ_ATTACH_TOKEN for any bind but loopback, and
terminate mTLS in front of it