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.
Symptom:
Database error: __diesel_schema_migrations does not exist
or
Database connection failed: connection refused
The 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:
# PostgreSQL pg_isready -h localhost -p 5432 # SQLite — ensure the file path exists and is writable ls -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:# Directly via diesel diesel 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:diesel database reset --database-url "$DATABASE_URL"
Symptom:
Database error: database is locked
Multiple 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?;
Symptom:
Connection pool error: Pool::get() timed out after waiting for 30 seconds
or
Connection pool error: Unable to acquire connection from pool
Requests 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).
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 sweeper
Cause:
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:
-- PostgreSQL UPDATE task_executions SET status = 'Ready', claimed_by = NULL, heartbeat_at = NULL WHERE 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.
Symptom:
Workflow not found: my_workflow
or
Workflow not found in registry: my_workflow
You 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 exactly runner.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:
RUST_LOG=cloacina::registry::reconciler=debug cargo run
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.
Symptom:
Serialization error: invalid type: ...
or
Context error in task my_task: Serialization error: key 'data' is not valid JSON
Cause:
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(), }); }
Symptom:
Task timeout: my_task exceeded 300s
or
Pipeline timeout after 3600s
Tasks 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(()) }
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 cycles let 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:
RUST_LOG=cloacina::executor=debug,cloacina::execution_planner=debug cargo run
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.10" } cloacina-macros = { version = "0.10" } -
Verify the feature flags — computation graph support requires the
macrosfeature (on by default):[dependencies] cloacina = { version = "0.10", features = ["macros"] }
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:
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.
Symptom:
missing input: source 'my_source' not found in cache
The 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 configuration let 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.
Symptom:
Database error: schema "tenant_acme" does not exist
or
relation "tenant_acme.task_executions" does not exist
Cause:
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 cloaca admin = 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.schemata WHERE schema_name = 'tenant_acme';
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
Symptom:
SQL execution error: Failed to create schema 'tenant_new': permission denied for database cloacina
or
Invalid configuration: permission denied to create role
Cause:
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)
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):cloacinactl package pack ./my-package cloacinactl 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:
cloacinactl package build ./my-package --release
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_graph
but 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:
RUST_LOG=cloacina::registry::reconciler=info cargo run -
Verify the package is in the database:
SELECT package_name, version, status FROM workflow_packages WHERE package_name = 'my_package';
Symptom:
Package already exists: my_workflow v0.1.0
Attempting 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.
Symptom:
>>> import cloaca
Segmentation fault (core dumped)
or
ImportError: /path/to/cloaca.so: undefined symbol: _Py_Dealloc
or 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:
python --version # Must be >= 3.9 -
Check OpenSSL linkage:
# Linux ldd $(python -c "import cloaca; print(cloaca.__file__)") # macOS otool -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 multiprocessing multiprocessing.set_start_method("spawn") -
If building from source, ensure system OpenSSL is used (not vendored) to match libpq:
# Do NOT set OPENSSL_STATIC=1 # Ensure openssl-sys links to system OpenSSL cargo 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
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.
Symptom:
>>> import cloaca
>>> runner = cloaca.DefaultRunner("postgresql://...")
RuntimeError: Backend not available: postgres support was not compiled into this wheel
or 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:pip show cloaca # see installed wheel metadata pip 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:
# Install maturin pip install maturin # Build (defaults match the published wheel) maturin build --release --features "extension-module,postgres,sqlite,macros" # Or develop mode maturin develop --features "extension-module,postgres,sqlite,macros" -
For PostgreSQL on Linux, ensure
libpq-devis installed:sudo apt-get install libpq-dev
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 json from datetime import datetime # Convert datetime to ISO string ctx["timestamp"] = datetime.now().isoformat() # Convert bytes to base64 import base64 ctx["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()
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:
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))
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 needed ctx.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
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.
Symptom:
error: formatting check failed
Diff in src/my_file.rs
or
error: missing license header in src/new_file.rs
Commits 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:
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:
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(...) { }
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:
cargo doc --open -p cloacina -
Run the tutorial demos to identify all breakages:
angreal demos tutorials rust 01 angreal 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.
Symptom:
Error starting userland proxy: listen tcp4 0.0.0.0:5432: bind: address already in use
Docker 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:
# Linux ss -tlnp | grep 5432 # macOS lsof -i :5432 -
Stop the conflicting service:
# Stop local PostgreSQL brew services stop postgresql # macOS sudo systemctl stop postgresql # Linux -
Or remap ports in
docker-compose.yml:services: postgres: ports: - "5433:5432" # Use 5433 externallyThen update your
DATABASE_URL: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:
docker compose down -v && docker compose up -d
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:
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:
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.
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:
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.
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 < limit
cloacinactl execution list --limit 50 --offset 0
cloacinactl execution list --limit 50 --offset 50
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:cloacinactl -v status - Prefer
cloacinactl config set/config profile setover hand-editing — they write only known keys.
| 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 |
If your issue is not covered here:
-
Enable verbose logging:
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:
# Address sanitizer (nightly only) RUSTFLAGS="-Z sanitizer=address" cargo +nightly test # Under GDB gdb --args cargo test my_failing_test