Architecture

Data Flow

Where run state, attempt results, capacity counters, caches, and events physically live — and how they move.

Ductor's correctness properties come from where each kind of data lives and how it moves between Postgres and Redis. This page is the map: for each kind of state, which store owns it, who writes it, and how changes propagate.

The two stores, and what each owns

Ductor uses Postgres as the durable source of truth and Redis (or Dragonfly) as the fast, coordinating layer. The division is deliberate. (The durable eec_-prefixed tables below belong to Ductor's Enterprise Eventing Core — its event, task, and workflow store.)

DataStoreWriterWhy there
Workflow run statePostgres eec_workflow_runCoordinator onlyDurable, version-locked source of truth
Attempt resultsPostgres eec_workflow_step_attemptStep WorkersDurable, append-only record of work
Config aggregates (pools, rules)Postgres (event-sourced)Command sideAuditable, replayable history
Idempotency recordsPostgres eec_routing_idempotencyRouting planeDurable dedup across retries
Capacity / concurrency countersRedis (Lua-atomic)Hot pathFast, atomic, cross-pod without a lock
Pool / rule cacheRedisCache layerLow-latency reads, pub/sub invalidation
Step tasks & wakeupsRedis (tiered queue)DispatcherFair, durable-enough delivery plane

The rule of thumb: anything that must survive a crash lives in Postgres; anything that must be fast and shared across pods lives in Redis. Redis is a coordinating accelerator, not the system of record.

Workflow execution flow

Coordinator.Tick next tick folds in the result Trigger Create run(Postgres) Coordinator wakeup(Redis) Step Worker executes Write attempt result(Postgres) Coordinator wakeup (Redis) Load state (Postgres) Compute payload (pure) Commit (Postgres, record_version) Dispatch step tasks(Redis: SyncMatch / tiered queue)

Run state and attempt results both live in Postgres, so the durable record of "where is this run" and "what did each step produce" survives any pod restart. Redis carries the coordination — wakeups and task delivery — which can be redriven if lost. See The Coordinator-Worker Model.

Routing decision flow

Routable event Reserve idempotency(Postgres) Validate Enrich(read cache: Redis) Filter Select Assign Finalizepersist decision (Postgres)+ capacity counters (Redis Lua)+ emit events & metrics

The routing pipeline reads cached pools and rules from Redis on the enrich stage, then commits its decision to Postgres and adjusts capacity counters in Redis atomically. The idempotency reservation in Postgres is what makes a retried request reuse its decision.

Config change and cache invalidation

Config changes fan out through the append-only bus:

Pool / rule change Append event(Postgres) Redis pub/sub fanout Every pod's cache invalidatordrops the stale entry

The event is persisted before it's published, so a dropped pub/sub message degrades cache freshness (until the next read reconciles), never correctness. Every pod subscribes and invalidates its local cache, so a rule edit propagates fleet-wide without a redeploy.

Capacity counters

Concurrency and rate ceilings are Redis counters mutated by Lua scripts, so an increment-check-decrement is a single atomic step with no read-modify-write race across pods. This is the same primitive used by entitlements on the routing hot path and by the tiered queue at lease time — one mechanism, two call sites.

Postgres decides, Redis coordinates

If you remember one thing about Ductor's data flow: Postgres is the arbiter of truth and correctness; Redis makes it fast and fair. Every durable decision is a Postgres commit. Redis carries wakeups, task delivery, caches, and counters — all of which can be rebuilt or redriven from the durable state if they're lost.

Where to go next