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.)
Choose the backend for your posture
Section titled “Choose the backend for your posture”- 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.
Size the runner for your load
Section titled “Size the runner for your load”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.
Build for at-least-once
Section titled “Build for at-least-once”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.
Observe it
Section titled “Observe it”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.
Shut down cleanly
Section titled “Shut down cleanly”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.
If you load packaged workflows
Section titled “If you load packaged workflows”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.
If you register cron schedules programmatically
Section titled “If you register cron schedules programmatically”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.)
Multiple replicas
Section titled “Multiple replicas”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.