Execution Reliability
Orchka executes workflows on a BullMQ + Redis engine. Every node runs as its own background job, and the engine is built so that a failure, a crash, or a restart loses as little work as possible. This page explains the reliability guarantees and the knobs you control.
These behaviors are handled by the workflow engine and the BullMQ worker. They apply to every workflow automatically — there is nothing to configure per workflow, except the optional parallel execution flag.
Execution model in one paragraph
When a workflow is triggered, the engine creates an execution record, sorts the nodes, and enqueues the first node. Each node job runs the node, publishes a real-time event to Redis, and then chains the next node(s). Step history is streamed to Redis for the live UI and flushed to Postgres at checkpoints and on completion — so the database isn't hit on every step.
Retries
A failed node is retried automatically — but only when retrying is safe. The engine classifies each node type:
| Class | Behavior | Node types |
|---|---|---|
| Idempotent | Retried up to 3× with exponential backoff (1s, 2s, 4s) | AI nodes (Generate, Extract, Classify, Agent), control nodes (If, Switch, Loop, Wait, Merge), triggers, HTTP GET/HEAD |
| Side-effecting | No automatic retry — runs once, then goes to the dead-letter queue on failure | Social posts (X, LinkedIn, Facebook, Instagram, Threads, Discord, Reddit, Bluesky, Mastodon, Pinterest), HTTP POST/PUT/PATCH/DELETE |
Why side-effecting nodes don't auto-retry: retrying a node re-runs it from scratch. For a node that already posted a tweet or sent a Discord message, that would post it twice. Orchka runs these once and surfaces the failure in the dead-letter queue for a deliberate manual retry, rather than risking a duplicate.
Some failures are never retried even for idempotent nodes — for example a missing credential or an invalid workflow. These are marked non-retryable so the job fails fast instead of burning three attempts on an error that can't succeed.
The HTTP Request node's class is chosen from its method: a GET is treated
as safe to retry, a POST is not.
Dead-letter table
When a job fails terminally — retries exhausted, or a side-effecting node that never retries — it is recorded in a dead-letter table in Postgres instead of being discarded. Each entry keeps:
- the full job payload (so it can be re-run unchanged),
- which node, workflow, and execution it belonged to,
- the number of attempts made, and the error message + stack,
- a status:
PENDING,REQUEUED, orDISCARDED.
This means a failed post or a flaky integration is never silently lost. Because the entries live in Postgres (not Redis), they are durable, queryable, and scoped to your organization — and surfaced through the API:
| Operation | What it does |
|---|---|
| List | Paginated, org-scoped list of failed jobs, filterable by status/workflow |
| Pending count | How many failures still need triage (e.g. for a badge) |
| Retry | Replays the stored payload onto the queue — the workflow resumes from that node — and marks the entry REQUEUED |
| Discard | Dismisses a failure without retrying it (DISCARDED) |
These are surfaced in the app under Executions → Failed Jobs — a tab next to the execution list, with a badge showing how many failures are still pending, and per-row Retry (with a confirmation) and Discard actions.
A retry replays the exact failed node and then continues the workflow from there. For side-effecting nodes this is a deliberate action you take after confirming the side effect didn't already happen — which is the whole reason those nodes don't retry automatically.
Durability: surviving a restart
Redis holds all in-flight job state and the live step history. If Redis were in-memory only, a restart would drop every running workflow. Orchka ships a Redis configured for append-only persistence:
# docker-compose.yml (repo root)
services:
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
volumes:
- orchka-redis-data:/dataappendonly yes— every change is written to the append-only file (AOF).appendfsync everysec— the AOF is fsync'd once per second, so a crash loses at most ~1 second of state, with negligible throughput cost.
If you run your own Redis, enable AOF persistence there too. Without it, a
Redis restart loses in-flight executions regardless of everything else on this
page. Start the bundled one with docker compose up -d redis.
Periodic checkpoints
Step history is flushed from Redis to Postgres every 25 nodes (and every 25 loop iterations), not only when the workflow finishes. Combined with AOF, a crash mid-run resumes with most of its history already persisted. Checkpointing is idempotent — re-running it never creates duplicate step rows.
No silent truncation on long runs
Step events go to a per-execution Redis stream holding up to 100,000 events — comfortably above any realistic workflow, including a large loop that emits a pair of events per body node per iteration. Streams also give server-assigned ordering, so history is never reassembled out of order.
Durable loops
Loop bodies execute as queued node jobs, one iteration at a time — not inline inside the loop's own job. That means:
- A crash resumes at the current iteration, not back at item 0. The cursor lives in the job payload, which Redis persists.
- A failure never repeats earlier side effects. Previously a retry of the loop job re-ran every completed iteration, so a failure at item 900 of 1000 could post 900 duplicates. Body nodes now retry individually, and the loop itself never retries as a unit.
Job delivery is at-least-once, so the single iteration that was in flight when a worker died runs again when the job is redelivered — a verified crash mid-way through a 20-item loop produces 21 body executions, not 20. Only that one iteration repeats; completed ones never do. If a body node performs a non-idempotent side effect, guard it with an idempotency key rather than assuming exactly-once.
- Body nodes get the full reliability stack: per-node retry classification, dead-letter parking, and step history (one row per node per iteration).
Waitinside a loop no longer blocks a worker. It uses the same delayed job mechanism as a top-levelWait, so a long wait inside a loop costs no worker capacity.- Nested loops and
If/Switchinside a loop body work correctly — each iteration re-decides its own branch, and inner loops iterate per outer item.
Parallel branch execution
By default, Orchka executes one node at a time, depth-first within a branch — so independent branches of a workflow run sequentially. An opt-in mode runs independent branches concurrently.
Sequential (default): 1 → 2 → 4 → 3
Parallel (opt-in): 1 → (2 and 3 at the same time) → 4Enable it by setting an environment variable for the worker process (the
worker reads apps/web/.env.local):
ORCHKA_PARALLEL_BRANCHES=1When enabled:
- Every node whose dependencies are satisfied is enqueued immediately, so independent branches progress at the same time.
- Shared execution state (each node's output, which nodes have completed, branch decisions) lives in Redis rather than being threaded through job payloads, so a fan-in / Merge node correctly sees the output of every incoming branch.
- A node with multiple parents is enqueued exactly once (atomic claim), and the last branch to finish finalizes the run.
Parallel execution is off by default and should be validated in a staging environment before enabling in production.
Loops behave identically in both modes: iterations are sequential by definition, so a loop body runs one node at a time either way.
What's still being hardened
The engine's roadmap (packages/workflow-engine/src/ROADMAP.md) tracks the
remaining items — notably execution retention and partitioning for very
high-volume workspaces.