Running embedded in production
Embedding Cloacina is a production-legitimate way to run it — not a stepping stone. This guide covers running the library as a long-lived component of your own service. (If you’d rather operate a standalone control plane, that’s the service door — a different choice, not a graduation.)
- SQLite — single process. Great for embedding in a single-instance app, CLIs, and local/dev. No multi-replica coordination.
- PostgreSQL — required for multiple replicas of your app sharing orchestration state, and for schema-isolated multi-tenancy. See Database Backends.
The backend is chosen by the connection URL at runtime — no recompile.
DefaultRunnerConfig exposes the knobs that matter under load. The defaults are
sensible for small embedded use; raise them deliberately:
| Field | Default | Raise it when… |
|---|---|---|
max_concurrent_tasks |
4 | tasks are I/O-bound and you have headroom |
db_pool_size |
10 | concurrency or replica count is high |
task_timeout (Python: task_timeout_seconds) |
300 s | legitimate tasks run longer |
workflow_timeout (Python: workflow_timeout_seconds) |
3600 s | whole workflows legitimately run longer |
enable_recovery |
true | keep on in production (reclaims stalled work) |
The Rust struct’s fields are private (#[non_exhaustive]) — construct it through
its builder:
use cloacina::runner::{DefaultRunner, DefaultRunnerConfig};
let config = DefaultRunnerConfig::builder()
.max_concurrent_tasks(16)
.db_pool_size(24)
.build()?;
let runner = DefaultRunner::with_config(
"postgresql://user:pass@db:5432/app",
config,
).await?;
import cloaca
config = cloaca.DefaultRunnerConfig(max_concurrent_tasks=16, db_pool_size=24)
runner = cloaca.DefaultRunner.with_config(
"postgresql://user:pass@db:5432/app", config,
)
See the full field list in Reference · Configuration.
Execution is at-least-once with recovery — after a crash, in-flight work is reclaimed and may re-run. Make tasks idempotent: writing the same row twice, re-sending the same message, etc., must be safe. This is the single most important production property to design for.
The embedded runner records the same execution state the server reads — poll it, subscribe to status callbacks, and query cron/trigger history in-process. See Monitoring Executions for the embedded observation APIs, and Observe Execution State for the metrics/logs/tracing surfaces of server and daemon deployments.
Always call shutdown() (Rust: .shutdown().await?; Python: runner.shutdown(),
or use the DefaultRunner context manager) so the connection pool drains and
in-flight bookkeeping completes. Tie it to your service’s graceful-shutdown path.
Two registry defaults are fine for development and wrong for production:
- Set an explicit registry path. With the default
"filesystem"storage backend and noregistry_storage_path, packages are stored understd::env::temp_dir()/cloacina_registry— a temp directory that can be wiped on reboot or by temp-cleaners, silently losing your registered packages. Set.registry_storage_path(Some("/var/lib/myapp/cloacina_registry".into()))on the config builder (or use the"sqlite"/"postgres"database-backed storage via.registry_storage_backend(...)). - Registry construction failure is non-fatal. If the registry backend fails
to construct at startup (bad path, permissions, unknown backend name), the
runner logs
Failed to create workflow registry: ...at ERROR and keeps running without a registry — packaged workflows silently never load. If you depend on packaged workflows, alert on that log line or verify after startup thatrunner.get_workflow_registry().awaitreturnsSome.
runner.register_cron_workflow(name, expr, tz) creates a new schedule row on
every call — it does not upsert. Calling it in your service’s startup path
means every restart adds a duplicate schedule, and the workflow starts running
N times per tick. Either register once (out-of-band), or check
list_cron_schedules for an existing entry before registering. (The registry
reconciler’s own path for packaged #[trigger(cron = ...)] declarations is
upsert-idempotent — this asymmetry only bites programmatic registration.)
Running several instances of your app against one Postgres is supported — the
runners coordinate through the database (claiming work atomically). Use Postgres
(not SQLite), keep enable_recovery on, and ensure tasks are idempotent. See
Horizontal Scaling for the
coordination model.