Troubleshooting
This guide covers common issues encountered when developing with or deploying Cloacina, organized by category. Each entry includes the symptom you observe, the underlying cause, and a step-by-step solution.
Database Issues
Section titled “Database Issues”1. Migration failed / schema errors on startup
Section titled “1. Migration failed / schema errors on startup”Symptom:
Database error: __diesel_schema_migrations does not existor
Database connection failed: connection refusedThe runner fails to start and reports migration or schema-related errors.
Cause:
The database has not been initialized with the required schema, or the connection string is incorrect. This commonly happens when:
- You are pointing at an empty database that has never had migrations run.
- The PostgreSQL/SQLite service is not running.
- The
DATABASE_URLenvironment variable is set to a stale or incorrect path.
Solution:
-
Verify the database service is running:
Terminal window # PostgreSQLpg_isready -h localhost -p 5432# SQLite — ensure the file path exists and is writablels -la /path/to/your/database.sqlite -
Run migrations. Cloacina applies migrations automatically on startup (e.g.
DefaultRunner::new/ builderbuild()). If you need to run them manually:Terminal window # Directly via dieseldiesel migration run --database-url "$DATABASE_URL"Note:
DefaultRunner::with_database(...)does not run migrations — the caller must have migrated first. -
If you see
__diesel_schema_migrations does not exist, the database was likely created but never had migrations applied. Drop and recreate:Terminal window diesel database reset --database-url "$DATABASE_URL"
2. “Database is locked” with SQLite (concurrent access)
Section titled “2. “Database is locked” with SQLite (concurrent access)”Symptom:
Database error: database is lockedMultiple operations fail intermittently with lock errors when using the SQLite backend.
Cause:
SQLite allows only one writer at a time. When multiple runner instances or threads attempt concurrent writes, SQLite returns SQLITE_BUSY. This is especially common when:
- Running multiple test processes against the same SQLite file.
- Using SQLite in a multi-runner deployment (which is not supported).
- The WAL mode is not enabled.
Solution:
-
For development/testing: Ensure each test uses its own database file. Cloacina’s test harness creates temporary databases per test. Never share a SQLite file across concurrent processes.
-
Enable WAL mode if you must use SQLite with moderate concurrency:
PRAGMA journal_mode=WAL;PRAGMA busy_timeout=5000; -
For production: Switch to PostgreSQL. SQLite is suitable only for single-runner, single-tenant deployments:
let db = Database::new("postgresql://user:pass@localhost/cloacina").await?;
3. Connection pool exhausted
Section titled “3. Connection pool exhausted”Symptom:
Connection pool error: Pool::get() timed out after waiting for 30 secondsor
Connection pool error: Unable to acquire connection from poolRequests or task executions hang and then fail with pool errors.
Cause:
All connections in the pool are in use and none are being returned. Common causes:
db_pool_sizeis set too low for your concurrency level.- Long-running transactions are holding connections.
- A deadlock in application code prevents connections from being released.
Solution:
-
Increase the pool size in your runner configuration:
let config = DefaultRunnerConfig::builder().db_pool_size(20) // Default is 10.build(); -
Ensure task code does not hold database connections across await points. Each DAL operation should acquire and release its connection within the same scope.
-
Monitor pool metrics. If connections are leaking, check for panics in task code that may skip cleanup. Enable
RUST_LOG=deadpool=debugto see pool activity. -
As a rule of thumb, set pool size to:
max_concurrent_tasks + 5(headroom for scheduler, sweeper, and reconciler).
4. Stale claims blocking task execution
Section titled “4. Stale claims blocking task execution”Symptom:
Tasks remain in “Running” state indefinitely. New executions of the same workflow are blocked waiting for the stale task to complete. Logs may show:
CRITICAL: Context saved but mark_completed failed — task may be re-executed by stale claim sweeperCause:
A runner instance crashed (or was killed with SIGKILL) while holding task claims. The heartbeat stopped updating, but the claim record was never released. Until the stale claim sweeper detects and clears these claims, those tasks block pipeline progress.
Solution:
-
Wait for automatic recovery. The stale claim sweeper runs at
stale_claim_sweep_interval(default 30s) and marks claims as stale if the heartbeat is older thanstale_claim_threshold(default 60s). Once released, the task will be rescheduled. -
Tune thresholds for faster detection. Both are builder methods on
DefaultRunnerConfigBuilder(defaults 30s and 60s respectively). Note thatstale_claim_thresholdmust exceedheartbeat_intervalorbuild()fails:let config = DefaultRunnerConfig::builder().stale_claim_sweep_interval(Duration::from_secs(15)).stale_claim_threshold(Duration::from_secs(30)).build()?; -
Manual intervention — if you need immediate recovery, reset stuck tasks:
-- PostgreSQLUPDATE task_executionsSET status = 'Ready', claimed_by = NULL, heartbeat_at = NULLWHERE status = 'Running'AND heartbeat_at < NOW() - INTERVAL '2 minutes'; -
Always use fresh databases when testing packaged workflows. Stale pipeline state from previous test runs causes misleading failures.
Runtime Errors
Section titled “Runtime Errors”5. “Workflow not found” after registration
Section titled “5. “Workflow not found” after registration”Symptom:
Workflow not found: my_workflowor
Workflow not found in registry: my_workflowYou registered a workflow but execution fails with “not found.”
Cause:
This happens when:
- The workflow was registered in a different runner instance (multi-tenant deployments without shared state).
- The workflow package was registered in the database but the reconciler has not yet loaded it into the in-memory registry.
- There is a name mismatch between registration and execution (e.g., module prefix differences).
Solution:
-
Check the reconciler interval. After package registration, the reconciler must run before the workflow is available in-memory. Default interval is 5 seconds:
let config = DefaultRunnerConfig::builder().registry_reconcile_interval(Duration::from_secs(10)).build()?; -
Verify the exact workflow name including any namespace prefix:
// Registration name must match execution name exactlyrunner.execute("my_package::my_workflow", context).await?; -
Enable startup reconciliation (on by default) to ensure packages are loaded before accepting work:
let config = DefaultRunnerConfig::builder().registry_enable_startup_reconciliation(true).build(); -
Check logs for reconciler activity:
Terminal window RUST_LOG=cloacina::registry::reconciler=debug cargo run
6. Task panics not being caught (unwind safety)
Section titled “6. Task panics not being caught (unwind safety)”Symptom:
The runner process crashes entirely rather than marking a task as failed. You see:
thread 'tokio-runtime-worker' panicked at 'index out of bounds: ...'Cause:
By default, Cloacina executes tasks on blocking threads via spawn_blocking and catches panics with std::panic::catch_unwind. However, this only works if:
- The task’s
executemethod isUnwindSafe(a Rust safety guarantee ensuring data remains valid after a panic). - The panic occurs in Rust code (FFI panics are undefined behavior).
- The panic does not corrupt shared state held across the unwind boundary.
If a task holds a &mut reference or non-unwind-safe type across the panic point, the catch may not activate.
Solution:
-
Ensure tasks are self-contained. Avoid holding references to external mutable state within the
executemethod. -
Use
AssertUnwindSafewrappers if you need to pass non-unwind-safe types:use std::panic::AssertUnwindSafe;async fn execute(&self, ctx: &mut Context<Value>) -> Result<(), TaskError> {let result = std::panic::catch_unwind(AssertUnwindSafe(|| {// potentially panicking code}));match result {Ok(()) => Ok(()),Err(_) => Err(TaskError::ExecutionFailed {message: "Task panicked".to_string(),task_id: self.name().to_string(),timestamp: Utc::now(),}),}} -
For Python tasks, panics in PyO3 code will abort the process. Ensure Python code does not trigger Rust-level panics. Use proper error handling on the Python side.
7. Context serialization failures (non-JSON types)
Section titled “7. Context serialization failures (non-JSON types)”Symptom:
Serialization error: invalid type: ...or
Context error in task my_task: Serialization error: key 'data' is not valid JSONCause:
The Context<T> requires all stored values to be serializable to JSON (via serde_json::Value). Types that cannot be represented in JSON will fail:
- Byte arrays (use base64 encoding instead)
- Function pointers or closures
- Types without
Serialize/Deserializederives - Infinity or NaN floating point values
Solution:
-
Ensure all context types derive Serde traits:
#[derive(Serialize, Deserialize)]struct MyData {name: String,count: u64,} -
For binary data, encode as base64 before storing:
use base64::Engine;let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);ctx.insert("binary_data", encoded)?; -
For complex types, implement custom serialization or store only the data needed for downstream tasks.
-
Check for NaN/Infinity in floating point values — JSON does not support these:
if value.is_nan() || value.is_infinite() {return Err(TaskError::ExecutionFailed {message: "Cannot serialize NaN/Infinity to JSON context".to_string(),task_id: self.name().to_string(),timestamp: Utc::now(),});}
8. “Task timeout exceeded” — causes and tuning
Section titled “8. “Task timeout exceeded” — causes and tuning”Symptom:
Task timeout: my_task exceeded 300sor
Pipeline timeout after 3600sTasks or entire pipelines are forcibly terminated after the timeout period.
Cause:
The default timeouts are:
- Task timeout (
task_timeout): 300 seconds (5 minutes) - Workflow timeout (
workflow_timeout): 3600 seconds (1 hour) — applies only to the blockingexecute()wait loop, not toexecute_asynchandles.
Tasks that perform long-running operations (large data transfers, external API calls with retries, ML training) may exceed these limits.
Solution:
-
Increase task timeout:
let config = DefaultRunnerConfig::builder().task_timeout(Duration::from_secs(1800)) // 30 minutes.build()?; -
Increase workflow timeout:
let config = DefaultRunnerConfig::builder().workflow_timeout(Some(Duration::from_secs(7200))) // 2 hours.build()?; -
Disable workflow timeout for unbounded workflows:
let config = DefaultRunnerConfig::builder().workflow_timeout(None).build()?; -
Better approach — break long tasks into smaller steps. Save intermediate results to context keys so progress is recoverable:
async fn execute(&self, ctx: &mut Context<Value>) -> Result<(), TaskError> {for (i, chunk) in data.chunks(1000).enumerate() {let result = process_chunk(chunk)?;ctx.insert(format!("chunk_{i}"), result)?; // Save progress to context}Ok(())}
9. Deadlocked workflows (runtime circular dependencies)
Section titled “9. Deadlocked workflows (runtime circular dependencies)”Symptom:
A workflow runs indefinitely with no tasks progressing. All tasks show “Pending” or “Waiting” state. The scheduler log shows no tasks becoming ready.
Cause:
While Cloacina detects cyclic dependencies at build time via ValidationError::CyclicDependency, runtime deadlocks can still occur when:
- Tasks are waiting on context keys that are never written by upstream tasks (implicit dependencies).
- Trigger rules reference task states that form a logical cycle not captured in the DAG.
- External systems that tasks depend on are themselves blocked.
Solution:
-
Check for implicit dependencies. If task B reads a context key that task A writes, but there is no explicit dependency edge from A to B, add it:
workflow.add_dependency("task_b", "task_a")?; -
Inspect the workflow graph for logical cycles:
// Validation catches explicit cycleslet result = workflow.validate();if let Err(ValidationError::CyclicDependency { cycle }) = result {eprintln!("Cycle detected: {:?}", cycle);} -
Add timeouts to trigger rules so workflows fail loudly rather than hanging silently.
-
Enable debug logging to see which tasks are blocked and why:
Terminal window RUST_LOG=cloacina::executor=debug,cloacina::execution_planner=debug cargo run
Computation Graphs
Section titled “Computation Graphs”10. “Unresolved module or unlinked crate cloacina_computation_graph”
Section titled “10. “Unresolved module or unlinked crate cloacina_computation_graph””Symptom:
error[E0433]: failed to resolve: use of undeclared crate or module `cloacina_computation_graph`or linker errors mentioning cloacina_computation_graph symbols.
Cause:
The #[computation_graph] macro expands into code that references types from the cloacina-computation-graph crate. If your Cargo.toml does not include this dependency (or only depends on the top-level cloacina crate without the right re-exports), compilation fails.
Solution:
-
For embedded mode (using the full
cloacinacrate), ensure you import through Cloacina’s re-exports:use cloacina::computation_graph::*; -
For packaged mode (standalone cdylib), add the dependency explicitly:
[dependencies]cloacina-computation-graph = { version = "0.12" }cloacina-macros = { version = "0.12" } -
Verify the feature flags — computation graph support requires the
macrosfeature (on by default):[dependencies]cloacina = { version = "0.12", features = ["macros"] }
11. Graph nodes not firing — reaction criteria not met
Section titled “11. Graph nodes not firing — reaction criteria not met”Symptom:
The computation graph is loaded and the accumulators are receiving data, but the graph function never executes. No output is produced.
Cause:
The graph scheduler fires the graph function only when the reaction criteria are satisfied:
when_any: At least one accumulator has received a new value since the last execution.when_all: All declared accumulators have received at least one value.
If using when_all and one source never publishes, the graph will never fire.
Solution:
-
Check the reaction mode on the reactor that triggers your graph. The mode lives on
#[reactor(criteria = ...)]; the graph macro references it by name:#[cloacina_macros::reactor(name = "my_reactor",accumulators = [source1, source2],criteria = when_any(source1, source2),)]pub struct MyReactor;#[cloacina_macros::computation_graph(trigger = reactor("my_reactor"),graph = { ... })] -
Verify all sources are publishing. Enable debug logging for the scheduler:
Terminal window RUST_LOG=cloacina::computation_graph::scheduler=debug cargo run -
Check the input cache — if a source name in the graph does not match the actual accumulator name, the cache entry will never appear:
// Graph expects "market_data" but publisher sends to "market-data"// These do NOT match — use consistent naming -
For
when_allmode, ensure all sources produce at least one initial value. Consider usingwhen_anyduring development for easier debugging.
12. Accumulator not receiving events — channel closed
Section titled “12. Accumulator not receiving events — channel closed”Symptom:
missing input: source 'my_source' not found in cacheThe accumulator exists but never receives data. Logs may show the channel was dropped or closed.
Cause:
The accumulator’s internal channel was closed because:
- The producer (publisher) was dropped before the graph started consuming.
- The channel capacity was exhausted and the producer timed out (backpressure).
- The graph was registered after the producer started, missing the initial messages.
Solution:
-
Ensure registration order: Register the computation graph before starting producers. The graph scheduler creates channels during graph registration.
-
Check channel capacity. If using bounded channels, increase the buffer or switch to unbounded:
// In scheduler configurationlet scheduler = ComputationGraphScheduler::new(config); -
Verify source names match exactly between the publisher and the graph declaration. Source names are case-sensitive and use the
SourceNametype. -
Check the payload encoding. The computation-graph wire format is bincode in all build profiles (debug and release encode identically), so a debug producer feeding a release consumer is fine. If deserialization fails, the payload’s Rust type doesn’t match the accumulator’s declared boundary type — compare the producer’s serialized struct against the entry node’s parameter type.
Multi-tenancy
Section titled “Multi-tenancy”13. “Schema does not exist” — first-time setup
Section titled “13. “Schema does not exist” — first-time setup”Symptom:
Database error: schema "tenant_acme" does not existor
relation "tenant_acme.task_executions" does not existCause:
The tenant schema has not been provisioned. In Cloacina’s multi-tenant PostgreSQL mode, each tenant operates in an isolated schema. Before a tenant can use the system, an administrator must create the schema and run migrations within it.
Solution:
-
Use the DatabaseAdmin API to provision the tenant:
use cloacina::database::admin::{DatabaseAdmin, TenantConfig};let admin = DatabaseAdmin::new(database);let credentials = admin.create_tenant(TenantConfig {schema_name: "tenant_acme".to_string(),username: "acme_user".to_string(),password: String::new(), // auto-generates secure password}).await?; -
Via Python bindings (PostgreSQL URLs only —
DatabaseAdminrejects SQLite):import cloacaadmin = cloaca.DatabaseAdmin(database_url)config = cloaca.TenantConfig("tenant_acme","acme_user",None, # omitted password auto-generates a secure one)creds = admin.create_tenant(config) -
Verify the schema exists:
SELECT schema_name FROM information_schema.schemataWHERE schema_name = 'tenant_acme';
14. Tenant isolation failures — using wrong runner instance
Section titled “14. Tenant isolation failures — using wrong runner instance”Symptom:
Workflows from tenant A are visible to tenant B, or tasks execute with the wrong tenant context. Data appears to “leak” between tenants.
Cause:
Each runner instance is bound to a specific tenant via its search_path or connection configuration. If runners share a connection pool or if a runner is misconfigured to use the wrong schema, isolation breaks.
Solution:
-
Each tenant requires its own runner instance bound to its schema:
// Tenant-specific runner (PostgreSQL schema isolation)let runner = DefaultRunner::with_schema("postgresql://acme_user:pass@host/db","tenant_acme",).await?; -
Never share a
DefaultRunneracross tenants. The runner’s database pool is tied to one schema. -
Verify isolation by checking the current schema:
SHOW search_path; -- Should show the tenant's schema -
For the
default_tenant_idin reconciler config, ensure it matches the actual schema the runner operates in:// ReconcilerConfig default_tenant_id must match the connection's search_path
15. Admin API permissions — PostgreSQL role requirements
Section titled “15. Admin API permissions — PostgreSQL role requirements”Symptom:
SQL execution error: Failed to create schema 'tenant_new': permission denied for database cloacinaor
Invalid configuration: permission denied to create roleCause:
The DatabaseAdmin operations require elevated PostgreSQL privileges. The admin connection must use a role that has:
CREATEprivilege on the database (for schema creation)CREATEROLEprivilege (for creating tenant users)- Ownership or superuser access for granting permissions
Solution:
-
Create a dedicated admin role:
CREATE ROLE cloacina_admin WITH LOGIN PASSWORD 'secure_pass' CREATEROLE;GRANT CREATE ON DATABASE cloacina TO cloacina_admin;GRANT ALL ON SCHEMA public TO cloacina_admin; -
Use the admin role only for provisioning — not for day-to-day runner operations:
// Admin connection (elevated privileges)let admin_db = Database::new("postgresql://cloacina_admin:pass@host/cloacina").await?;let admin = DatabaseAdmin::new(admin_db);// Tenant runner connection (limited privileges)let tenant_db = Database::new(&credentials.connection_string).await?; -
Validate schema and username before creation. Cloacina validates inputs to prevent SQL injection:
- Schema names: must start with a letter or underscore, contain only alphanumerics and underscores
- Usernames: same constraints, plus no reserved names (e.g.,
postgres,admin)
Packaging
Section titled “Packaging”16. “No bin target available for cargo run” — library vs binary crates
Section titled “16. “No bin target available for cargo run” — library vs binary crates”Symptom:
error: a bin target must be available for `cargo run`When trying to cargo run a workflow package.
Cause:
Workflow packages are compiled as cdylib (dynamic libraries), not binary executables. They are loaded by the runner at runtime, not run directly.
Solution:
-
Packages are not meant to be run directly. Pack the source into a
.cloacinaarchive and register it (upload to a server, or drop it in a daemon watch directory):Terminal window cloacinactl package pack ./my-packagecloacinactl package upload ./my-package/my-package.cloacina -
Do not hand-add
[lib] crate-typeor apackagedfeature. In the current authoring model the compiler injects thecdylibcrate-type and thepackagedfeature when it builds the package — your source crate stays a plain library withcloacina_workflow_plugin::package!()at the crate root. See the package! macro reference. -
To check your package compiles locally:
Terminal window cloacinactl package build ./my-package --release
17. Reconciler not loading packages — timing and polling interval
Section titled “17. Reconciler not loading packages — timing and polling interval”Symptom:
You registered a package via the API, but the workflow does not appear in the runner’s task registry. Logs show:
Registered computation graph constructor: my_graphbut no corresponding “loaded workflow” message.
Cause:
The registry reconciler polls for new packages on a fixed interval (default: 5 seconds). After registration in the database, there is a short delay before the in-memory registry is updated. For Rust packages there is also a compile step in between: the workflow only loads once a cloacina-compiler instance has built it to build_status = 'success'.
Solution:
-
Tune the reconcile interval if needed:
let config = DefaultRunnerConfig::builder().registry_reconcile_interval(Duration::from_secs(5)).build()?; -
Ensure startup reconciliation is enabled (default: true). This runs a full reconciliation before the runner starts accepting work:
.registry_enable_startup_reconciliation(true) -
Check reconciler logs:
Terminal window RUST_LOG=cloacina::registry::reconciler=info cargo run -
Verify the package is in the database:
SELECT package_name, version, status FROM workflow_packagesWHERE package_name = 'my_package';
18. Package version conflicts — same name/version already registered
Section titled “18. Package version conflicts — same name/version already registered”Symptom:
Package already exists: my_workflow v0.1.0Attempting to register a package that has the same name and version as one already in the registry.
Cause:
Cloacina enforces unique (package_name, version) pairs. Re-registering the same version is rejected to prevent accidentally overwriting a running package.
Solution:
-
Bump the version in your package manifest:
[package]name = "my_workflow"version = "0.1.1" # Increment from 0.1.0 -
Unregister the old version first if you intentionally want to replace it (the registry trait methods are
unregister_workflow/register_workflow, reached via the runner’s registry handle):let registry = runner.get_workflow_registry().await.expect("registry reconciler enabled");registry.unregister_workflow("my_workflow", "0.1.0").await?;registry.register_workflow(std::fs::read("updated.cloacina")?).await?; -
Check for active executions — a package cannot be unregistered while workflows are running:
Package is in use: my_workflow v0.1.0 has 3 active executionsWait for active executions to complete, or cancel them before unregistering.
Python (Cloaca)
Section titled “Python (Cloaca)”19. ImportError / SIGSEGV on import — Python version mismatch, rpath issues
Section titled “19. ImportError / SIGSEGV on import — Python version mismatch, rpath issues”Symptom:
>>> import cloacaSegmentation fault (core dumped)or
ImportError: /path/to/cloaca.so: undefined symbol: _Py_Deallocor immediate crash on import cloaca without any Python traceback.
Cause:
This is typically caused by:
- Python version mismatch: The wheel was built with
abi3-py39(stable ABI for Python 3.9+). Using Python 3.8 or earlier will fail. - OpenSSL/libpq conflicts: When the PostgreSQL feature is enabled, the shared library links against system OpenSSL. If the Python environment has a different OpenSSL in its runtime library path (rpath), symbol conflicts cause SIGSEGV.
- Fork safety with OpenSSL: Importing
cloacaafterfork()(e.g., in multiprocessing) can trigger SIGSEGV due to OpenSSL’s unsafe atexit handler. See diesel#3441.
Solution:
-
Verify Python version:
Terminal window python --version # Must be >= 3.9 -
Check OpenSSL linkage:
Terminal window # Linuxldd $(python -c "import cloaca; print(cloaca.__file__)")# macOSotool -L $(python -c "import cloaca; print(cloaca.__file__)")Ensure the OpenSSL version matches what libpq expects.
-
For fork-related SIGSEGV: Import
cloacain the parent process before forking, or usespawninstead offorkfor multiprocessing:import multiprocessingmultiprocessing.set_start_method("spawn") -
If building from source, ensure system OpenSSL is used (not vendored) to match libpq:
Terminal window # Do NOT set OPENSSL_STATIC=1# Ensure openssl-sys links to system OpenSSLcargo build --features extension-module -
Historical mitigations (kept here for reference; not in the current codebase):
- The pre-I-0096 codebase initialized OpenSSL early via
#[ctor]incloacina/src/database/connection.rs. That file no longer exists (connection/is now a directory) and thectordependency has been dropped. The crash has not recurred; if it does, this pattern is the known-good mitigation. See the Native crash troubleshooting (historical) subsection below for the full record and alternative approaches. - Test packages were cached with
OnceLockto force the forkingpackage_workflow()build to run before any DB connection init. If the crash returns, restoring this caching pattern is a quick workaround.
- The pre-I-0096 codebase initialized OpenSSL early via
-
Debugging tips:
- GDB slows execution enough to mask race conditions — if tests pass under GDB, suspect a timing issue.
- The SIGSEGV typically occurs during program exit when OpenSSL cleanup races with connection pool threads.
- Disable ASLR for reproducible crashes:
setarch $(uname -m) -R python -c "import cloaca" - Try AddressSanitizer:
RUSTFLAGS="-Z sanitizer=address" cargo build
Native crash troubleshooting (historical)
Section titled “Native crash troubleshooting (historical)”Historical document, merged from
docs/SIGSEGV_TROUBLESHOOTING.md. The#[ctor]-based OpenSSL early-init workaround described above was removed when the crate moved fromctor-based to inventory-based registration. The historical root-cause notes and alternative approaches are preserved here in case the failure mode resurfaces in CI or with a different libpq/OpenSSL combination.
Historical root cause. Tests that call package_workflow() spawn
cargo subprocesses via fork(). When this happens after the database
connection pool has initialized OpenSSL/libpq, the fork can cause
SIGSEGV on Linux due to OpenSSL’s unsafe atexit handler. See
diesel#3441.
Alternative approaches to try if the symptom resurfaces and the current fixes above don’t resolve it:
- Pre-build test packages before test run — use a
build.rsor pre-commit hook to build packages before tests start; store as static test fixtures. - Disable ASLR in CI for debugging — run with
setarch $(uname -m) -R cargo test .... Makes the crash reproducible if it’s ASLR-dependent. - Use AddressSanitizer or ThreadSanitizer — build with
RUSTFLAGS="-Z sanitizer=address"orsanitizer=thread. May reveal the actual memory issue. - Use diesel_async — switch from sync diesel with deadpool to diesel_async. Different connection handling may avoid the issue.
- Investigate bundled pq-sys behavior — check whether
pq-sys/bundledhas different OpenSSL linking behavior. May need to match OpenSSL versions more carefully. - Isolated subprocess spawning — spawn package builds in
completely isolated processes (not
fork). Usestd::process::Commandwith explicit environment clearing. - Lazy database initialization — delay database pool creation until after all subprocess work is done. Restructure tests to do all forking first.
Additional historical debugging notes: check ldd output (Linux)
or otool -L (macOS) on the loaded cloaca shared object to verify
which OpenSSL version is linked — the SIGSEGV signature is consistent
with a libpq ↔ OpenSSL version mismatch where the cleanup ordering at
exit races with the connection pool drop path.
20. “Backend not available” — missing feature flags in wheel
Section titled “20. “Backend not available” — missing feature flags in wheel”Symptom:
>>> import cloaca>>> runner = cloaca.DefaultRunner("postgresql://...")RuntimeError: Backend not available: postgres support was not compiled into this wheelor the same for a sqlite://... URL on a Postgres-only wheel.
Cause:
The Cloaca Python wheel is built with specific Cargo feature flags. A pre-built wheel may not include both backends:
postgres— PostgreSQL support (requires libpq)sqlite— SQLite support (bundled libsqlite3)
(There is no kafka feature — event-source backends ship as constructor provider crates, not core features.)
Solution:
-
Check which features are compiled in:
The
cloacaPython module does not currently expose afeatures()helper. To inspect which backend was compiled in, check whetherDefaultRunneraccepts apostgresql://...orsqlite://...URL — a wheel built without a given backend will raise at construction time. For container / packaged-Python deployments, inspect the wheel’s metadata:Terminal window pip show cloaca # see installed wheel metadatapip install cloacaThe published wheel ships both backends (the wheel’s maturin build enables
postgres,sqlite,macros, andextension-module). There are nocloaca[postgres]/cloaca[sqlite]pip extras — the package defines no optional dependencies. -
Build from source with required features:
Terminal window # Install maturinpip install maturin# Build (defaults match the published wheel)maturin build --release --features "extension-module,postgres,sqlite,macros"# Or develop modematurin develop --features "extension-module,postgres,sqlite,macros" -
For PostgreSQL on Linux, ensure
libpq-devis installed:Terminal window sudo apt-get install libpq-dev
21. Context type conversion errors between Python and Rust
Section titled “21. Context type conversion errors between Python and Rust”Symptom:
RuntimeError: Failed to convert Python object to Rust type: 'dict' object cannot be converted to 'String'or
Context error in task py_task: Serialization error: ...Cause:
The Python-Rust boundary uses pythonize (PyO3 + serde) for type conversion. Python types must map cleanly to JSON-compatible Rust types:
| Python | Rust/JSON |
|---|---|
str |
String |
int |
i64 / u64 |
float |
f64 |
bool |
bool |
None |
null |
dict |
Object |
list |
Array |
Types that fail: datetime (not JSON-native), bytes (use base64), custom classes without __dict__.
Solution:
-
Convert Python objects to JSON-friendly types before passing to context:
import jsonfrom datetime import datetime# Convert datetime to ISO stringctx["timestamp"] = datetime.now().isoformat()# Convert bytes to base64import base64ctx["data"] = base64.b64encode(raw_bytes).decode("utf-8") -
For custom classes, convert to dict:
ctx["my_obj"] = vars(my_object) # or my_object.__dict__ -
Avoid numpy arrays — convert to lists first:
ctx["array"] = my_numpy_array.tolist()
Performance
Section titled “Performance”22. Slow task scheduling — polling interval tuning
Section titled “22. Slow task scheduling — polling interval tuning”Symptom:
Tasks appear ready but take a long time (multiple seconds) to begin execution. There is visible latency between task completion and the next task starting.
Cause:
The scheduler polls for ready tasks at a fixed interval. The default scheduler_poll_interval is 100ms, which should be sufficient for most workloads. However:
- If you overrode it to a larger value, scheduling latency increases.
- High database query latency can make each poll cycle slow.
- Too many concurrent pipelines competing for poll cycles.
Solution:
-
Check and tune the poll interval:
let config = DefaultRunnerConfig::builder().scheduler_poll_interval(Duration::from_millis(50)) // Faster polling (min 10ms).build()?; -
Monitor database query time. If each poll takes >50ms, the bottleneck is the database:
Terminal window RUST_LOG=cloacina::execution_planner=trace cargo run -
Ensure database indexes exist on
task_executions.statusandtask_executions.pipeline_id. Migrations create these, but verify:SELECT indexname FROM pg_indexes WHERE tablename = 'task_executions'; -
For trigger-based scheduling, the base poll interval is separate (default 1s):
.trigger_base_poll_interval(Duration::from_millis(500))
23. High memory usage — large contexts being cloned
Section titled “23. High memory usage — large contexts being cloned”Symptom:
Runner memory grows continuously as workflows execute. Each active pipeline consumes significantly more memory than expected.
Cause:
The Context<serde_json::Value> is cloned for each task execution and stored with checkpoint state. If tasks insert large payloads (multi-MB JSON objects, encoded files), memory usage scales with: context_size * active_tasks * retry_attempts.
Solution:
-
Keep context payloads small. Store large data externally (S3, filesystem) and keep only references in context:
// Instead of:ctx.insert("huge_dataframe", large_json)?;// Do:ctx.insert("dataframe_path", "/tmp/output/frame_001.parquet")?; -
Clean up intermediate results in downstream tasks:
// Remove large intermediate values no longer neededctx.remove("intermediate_result"); -
Monitor per-pipeline memory via structured logging and metrics.
-
Reduce max concurrent tasks if memory is constrained:
.max_concurrent_tasks(2) // Fewer concurrent pipelines = less memory
24. Cron catchup storm after long downtime
Section titled “24. Cron catchup storm after long downtime”Symptom:
After the runner restarts from extended downtime, hundreds or thousands of workflow executions are triggered simultaneously. The system becomes overloaded.
Cause:
When a cron schedule row has catchup_policy = "run_all", the scheduler calculates missed execution times during the downtime window and enqueues them, bounded by cron_max_catchup_executions (default 100, builder cap 1000). The per-schedule default policy is "skip" — a storm means a schedule was explicitly set to run_all.
Solution:
-
Set the schedule’s catchup policy back to
"skip"(thecatchup_policycolumn on the schedule row) for schedules that do not need historical backfill. -
Limit catchup executions:
let config = DefaultRunnerConfig::builder().cron_max_catchup_executions(5) // At most 5 missed runs.build()?; -
Set maximum recovery age to ignore ancient missed executions:
.cron_max_recovery_age(Duration::from_secs(3600)) // Only catch up last hour -
For critical schedules that must catch up, ensure
max_concurrent_tasksis high enough to process the backlog without starving other work.
CI/Development
Section titled “CI/Development”25. Pre-commit hook failures — formatting, license headers
Section titled “25. Pre-commit hook failures — formatting, license headers”Symptom:
error: formatting check failed Diff in src/my_file.rsor
error: missing license header in src/new_file.rsCommits are rejected by pre-commit hooks.
Cause:
The repository enforces:
cargo fmtformatting on all Rust files.- Apache 2.0 license headers on all source files.
- Clippy lint checks.
Solution:
-
Fix formatting:
Terminal window cargo fmt --all -
Add license headers. Every
.rsfile must start with:/** Copyright 2025-2026 Colliery Software** Licensed under the Apache License, Version 2.0 (the "License");* ...*/ -
Run the full check locally before committing:
Terminal window angreal lint all(For the fuller pre-push loop,
angreal ci fastruns lint + unit tests without Docker.) -
For Clippy failures, fix warnings or add targeted allow attributes:
#[allow(clippy::too_many_arguments)]fn complex_function(...) { }
26. Tutorial/example compilation failures after API changes
Section titled “26. Tutorial/example compilation failures after API changes”Symptom:
error[E0599]: no method named `old_method` found for struct `Runner`Tutorials or examples fail to compile after pulling recent changes.
Cause:
API changes in the core crates may not be reflected in the tutorial and example code immediately. The CI runs example tests, but there can be drift between development branches.
Solution:
-
Check the latest API in the reference documentation or by reading the relevant source:
Terminal window cargo doc --open -p cloacina -
Run the tutorial demos to identify all breakages:
Terminal window angreal demos tutorials rust 01angreal demos tutorials python 01 -
Common API migration patterns:
- Method renamed: Check the changelog or grep for the old name to find the replacement.
- New required parameter: Look at
DefaultRunnerConfig::builder()for new fields with defaults. - Type moved to new module: Use
cargo docto find the new location.
-
CI retry logic exists for flaky tutorial tests. If a test fails intermittently but passes on retry, it is likely a timing issue rather than an API change.
27. Docker services not starting — port conflicts
Section titled “27. Docker services not starting — port conflicts”Symptom:
Error starting userland proxy: listen tcp4 0.0.0.0:5432: bind: address already in useDocker Compose fails to start required services (PostgreSQL, Kafka, etc.).
Cause:
Another process (often a local PostgreSQL or Kafka installation) is already bound to the required port.
Solution:
-
Check what is using the port:
Terminal window # Linuxss -tlnp | grep 5432# macOSlsof -i :5432 -
Stop the conflicting service:
Terminal window # Stop local PostgreSQLbrew services stop postgresql # macOSsudo systemctl stop postgresql # Linux -
Or remap ports in
docker-compose.yml:services:postgres:ports:- "5433:5432" # Use 5433 externallyThen update your
DATABASE_URL:Terminal window export DATABASE_URL="postgresql://user:pass@localhost:5433/cloacina"Note: the repo’s own dev stack (
.angreal/docker-compose.yaml) already does this — its Postgres publishes on host port 15432 precisely to avoid colliding with a local Postgres on 5432; harness and testDATABASE_URLs uselocalhost:15432. -
For CI environments, ensure the service startup order is correct and previous containers are cleaned up:
Terminal window docker compose down -v && docker compose up -d
Service Mode
Section titled “Service Mode”28. Rust package uploads stay “pending” forever
Section titled “28. Rust package uploads stay “pending” forever”Symptom:
You upload a Rust .cloacina package (cloacinactl package upload or POST /v1/tenants/{t}/workflows), the upload succeeds, but the workflow never becomes executable. cloacinactl workflow list never shows it, and the package row stays at build_status = "pending" indefinitely.
Cause:
A .cloacina archive contains source, not a compiled library. Rust packages must be compiled by a running cloacina-compiler service, which polls the same database for build_status = pending rows. Without a compiler, nothing ever transitions the row — and the server emits no warning about the missing compiler.
Solution:
-
Run a compiler service against the same database:
Terminal window cloacinactl compiler start --database-url "$DATABASE_URL"(The Helm chart’s
compiler.enableddefaults to false — enable it, or uploads stay pending.) -
Check compiler health and backlog:
Terminal window cloacinactl compiler status # probes [compiler].local_addrAdmins can also hit
GET /v1/compiler/statuson the server for{status, pending, building, seconds_since_heartbeat, ...}. -
Python packages are unaffected — the server installs the Python runtime itself and loads them without a compiler.
29. default_executor=fleet with no matching agents — silent non-execution
Section titled “29. default_executor=fleet with no matching agents — silent non-execution”Symptom:
The server runs with --default-executor fleet, workflows accept executions, but nothing ever runs. Executions sit in Pending/Running with no task progress and no errors.
Cause:
The fleet executor dispatches only to live agents registered under the same tenant as the work. A tenant with zero registered agents gets no execution — work waits (and eventually times out) rather than failing fast. Note that public is a real tenant: work in the public tenant needs agents whose API key is scoped to public specifically; an agent keyed to another tenant (or a tenant-less key) serves nothing.
Solution:
- Check the roster (admin):
GET /v1/agents— confirm at least one live agent exists for the tenant in question. - Start an agent with a key scoped to that tenant:
Terminal window cloacina-agent --server http://localhost:8080 --api-key <tenant-scoped-key> - Or fall back to in-process execution by starting the server with
--default-executor default.
30. Pagination: total is the page size, not the collection count
Section titled “30. Pagination: total is the page size, not the collection count”Symptom:
A script pages through GET /v1/tenants/{t}/executions (or any list endpoint) using total to decide when to stop, and terminates after the first page — or loops forever.
Cause:
List envelopes are {"items": [...], "total": N}, but total is set to the size of the returned page, not the table count. You cannot infer “more pages exist” from it.
Solution:
Page with limit/offset and stop when a page comes back with fewer than limit items:
# Stop when items.length < limitcloacinactl execution list --limit 50 --offset 0cloacinactl execution list --limit 50 --offset 5031. Broken config.toml silently ignored
Section titled “31. Broken config.toml silently ignored”Symptom:
You edited ~/.cloacina/config.toml (added a profile, set a key), but cloacinactl behaves as if the file doesn’t exist — typically failing with “no server configured” despite a default_profile being set.
Cause:
The config schema is deny_unknown_fields, so a single unknown or misspelled key rejects the whole file — and the loader treats a parse failure as “no config”: it logs a warn! and falls back to defaults instead of erroring. Because most client commands don’t initialize tracing, that warning is never printed.
Solution:
- Re-check the file against the schema in the CLI Reference — every key must be known; check section names (
[daemon],[compiler],[watch],[server],[profiles.<name>]). - Run with
-vso the parse warning is actually emitted:Terminal window cloacinactl -v status - Prefer
cloacinactl config set/config profile setover hand-editing — they write only known keys.
Quick Reference: Error to Solution
Section titled “Quick Reference: Error to Solution”| Error Message | Section |
|---|---|
Database error: database is locked |
#2 SQLite concurrent access |
Connection pool error: Pool::get() timed out |
#3 Connection pool exhausted |
Workflow not found: X |
#5 Workflow not found |
Task timeout: X exceeded Ys |
#8 Task timeout |
Pipeline timeout after Xs |
#8 Task timeout |
Serialization error: ... |
#7 Context serialization |
Circular dependency detected |
#9 Deadlocked workflows |
missing input: source 'X' not found in cache |
#12 Accumulator not receiving |
Package already exists: X vY |
#18 Package version conflicts |
Segmentation fault on Python import |
#19 SIGSEGV on import |
Backend not available |
#20 Missing feature flags |
schema "X" does not exist |
#13 First-time tenant setup |
No bin target available |
#16 Library vs binary crates |
Rust upload stuck at build_status = pending |
#28 No compiler service |
| Fleet executor runs nothing | #29 No same-tenant agents |
| Pagination stops early / loops | #30 total is page size |
| Edited config.toml has no effect | #31 Broken config silently ignored |
Getting More Help
Section titled “Getting More Help”If your issue is not covered here:
-
Enable verbose logging:
Terminal window RUST_LOG=cloacina=debug,cloacina::executor=trace cargo run -
Check the error type hierarchy in
crates/cloacina/src/error.rsfor the full set of structured errors. -
Search existing issues on the GitHub repository.
-
For SIGSEGV crashes, run under a debugger or sanitizer:
Terminal window # Address sanitizer (nightly only)RUSTFLAGS="-Z sanitizer=address" cargo +nightly test# Under GDBgdb --args cargo test my_failing_test