Database backends¶
Both packages run on SQLite, PostgreSQL, and MySQL/MariaDB. Pick with the
database_url you pass to bq.configure(...) or Cache(...).
Drivers¶
| Database | URL | Driver | Install |
|---|---|---|---|
| SQLite | sqlite:///path.db |
stdlib sqlite3 |
(built in) |
| PostgreSQL | postgresql+psycopg://user:pw@host/db |
psycopg 3 | …[postgres] |
| MySQL / MariaDB | mysql+pymysql://user:pw@host/db |
PyMySQL | …[mysql] |
pip install "firm-queue[postgres]" # queue on PostgreSQL (or [mysql])
pip install "firm-cache[postgres]" # cache on PostgreSQL (or [mysql])
Bare URLs are normalized for you: postgresql://… becomes postgresql+psycopg://… and
mysql://… becomes mysql+pymysql://…, so you don't have to remember the driver suffix. An
explicit +driver is always respected.
How concurrency stays correct on each¶
This is the one place the databases genuinely differ, and it's fully abstracted (see queue internals):
| Database | Claim transaction | Row locking |
|---|---|---|
| PostgreSQL / MySQL | ordinary transaction | FOR UPDATE SKIP LOCKED |
| SQLite | BEGIN IMMEDIATE write lock |
not needed (writers serialize) |
The same guarantee — two workers never claim the same job, two writers never lose a cache update — holds on all three.
Choosing one¶
- SQLite — zero setup, perfect for development, tests, single-process apps, and low-to-moderate
volume on one host. Its one writer at a time means write-heavy, multi-worker, multi-host
deployments will serialize on the write lock. WAL +
busy_timeoutkeep that smooth up to a point. On Kubernetes/shared storage it needs special care — see SQLite on Kubernetes. - PostgreSQL — the recommended default for production and anything multi-host: real
SKIP LOCKED, no global writer lock, scales horizontally. - MySQL / MariaDB — equally supported; same
SKIP LOCKEDpath. MariaDB 10.6+ supportsSKIP LOCKED.
SQLite on Kubernetes (and other shared/network storage)¶
SQLite on firm relies on two OS-level mechanisms for correctness: WAL mode (which uses a
sidecar -wal file plus a -shm shared-memory file) and byte-range file locks on the database
file. The job-claim path leans on this directly — on SQLite, BEGIN IMMEDIATE takes the single
write lock so two workers never claim the same job. Both mechanisms assume one host, one local
filesystem. Container orchestrators quietly violate that assumption, so read this before reaching
for a PersistentVolumeClaim.
The core problem: SQLite's file locking is unreliable on network filesystems, and WAL is worse —
the -shm file coordinates processes through shared memory that only works when they are on the same
host. The SQLite project states outright that WAL "does not work over a network filesystem." What
this means for the common PVC storage classes:
- NFS-backed /
ReadWriteManyvolumes (EFS, Filestore, mostnfsclasses) —fcntllocks over NFS are racy, and WAL's-shmcoordination isn't there at all. Expectdatabase is lockederrors, silently lost writes, or outright corruption.busy_timeout(default 5000 ms) masks contention on a local disk but cannot fix broken locking semantics. Avoid. - Block-based /
ReadWriteOncevolumes (EBS, GCE PD, Ceph RBD, most default classes) — these are real block devices with a normal filesystem, so SQLite + WAL works correctly, but only from one pod. The volume can only be mounted read-write by a single node at a time.
Multiple pods sharing one SQLite database is not supported. BEGIN IMMEDIATE only serializes
claimers that see the same file with working locks — which, per the above, they can't do safely over
a shared PVC. A ReadWriteOnce PVC won't even let two pods on different nodes mount it. This is a
fundamental SQLite limitation, not a firm bug.
| Deployment shape | Works? | Notes |
|---|---|---|
1 pod, ReadWriteOnce block PVC |
✅ | Correct SQLite + WAL. Fine for dev, low volume, single-tenant. |
1 pod, NFS / ReadWriteMany PVC |
⚠️ | Locking may misbehave; corruption possible even with one writer. Avoid. |
| 2+ pods, shared PVC | ❌ | Broken locking (NFS) or can't mount (RWO). The no-double-claim guarantee does not hold. |
| Any multi-replica / HA setup | ❌ | Use PostgreSQL. |
If you must run SQLite in Kubernetes, treat it as a single-writer appliance:
- Use a
StatefulSetwith a single replica and a block-backed (ReadWriteOnce) volume, not aDeployment. - Set the update strategy so a rolling update never runs two pods against the volume at once —
Recreate(Deployment) or theStatefulSetdefaultOnDelete/rolling behavior withreplicas: 1. Overlapping pods during a deploy is the most common way people corrupt the file. - Keep the
-waland-shmsidecar files on the same volume as the.dbfile (they will be, as long as the whole directory lives on the PVC).
For anything multi-pod, HA, or that you expect to scale — use PostgreSQL. Point firm at it with
the same database_url you'd pass for SQLite; the dialect layer switches to real
FOR UPDATE SKIP LOCKED and workers scale horizontally across pods with no shared-filesystem
requirement. This is the recommended production path.
MySQL specifics¶
The schema is tuned so MySQL behaves like the others:
value(cache) and large text columns map toLONGBLOB/LONGTEXT— plainBLOB/TEXTwould silently cap at 64 KiB.- Timestamps use
DATETIME(6)for sub-second precision (needed for correct ordering and the recurring(task_key, run_at)dedupe).
Migrations¶
Each package ships an Alembic baseline migration that creates its schema. Point Alembic at the
package's alembic.ini (or pass the URL via env / -x):
# queue
FIRM_QUEUE_DATABASE_URL=postgresql://localhost/myapp \
alembic -c alembic.queue.ini upgrade head
# cache
FIRM_CACHE_DATABASE_URL=postgresql://localhost/myapp \
alembic -c alembic.cache.ini upgrade head
# channel
FIRM_CHANNEL_DATABASE_URL=postgresql://localhost/myapp \
alembic -c alembic.channel.ini upgrade head
# audit
FIRM_AUDIT_DATABASE_URL=postgresql://localhost/myapp \
alembic -c alembic.audit.ini upgrade head
Each package keeps its own Alembic version table (firm_<module>_alembic_version), so all four
can migrate one shared database independently.
For development you can skip Alembic and call schema.create_all(engine) (queue) or let
Cache(create_schema=True) (the default) build the table. Either way the schema stays
upgradable: auto-creation also stamps the package's version table at the migration head, so a
later alembic upgrade picks up from the baseline instead of trying to re-run it.
What's tested¶
The full test suite runs against SQLite, PostgreSQL, and MySQL/MariaDB — the same correctness specs (no-double-claim under contention, concurrency block/promote, dispatch, recovery, cache upsert/increment/eviction) execute on every backend. See Testing & contributing.