Skip to content

Postgres Architecture ​

This page describes the Cyclone tables, indexes, and views in Postgres. It also describes the locks and the operational effects.

Tables Overview ​

TablePurposeChurn
cyclone_runsOne row for each run. The run is a workflow or a standalone job.Moderate
cyclone_stepsOne row for each step or action in a run.High
cyclone_depsA dependency edge between steps.Low
cyclone_schedulesA durable cron schedule definition.Lowest
cyclone_schedule_firesOne deduplication row for each schedule and slot.Low
cyclone_step_inputsOrdered input bindings for a fan-in from more than one source.Low
cyclone_signalsA signal payload that a wait node has not consumed.Low
cyclone_schema_versionThe record of each applied migration.Lowest

cyclone_runs ​

sql
CREATE TABLE cyclone_runs (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    workflow    text NOT NULL,          -- workflow name or standalone kind
    def_version text NOT NULL,          -- DAG topology fingerprint
    input       jsonb,
    state       text NOT NULL DEFAULT 'running',
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now(),
    finished_at timestamptz,            -- NULL until terminal
    deadline_at timestamptz
);

def_version is a fingerprint of the serialized DAG. Routing and tracing use it to find a schema mismatch between the submitter and the worker.

cyclone_steps ​

cyclone_steps has more changes than the other tables. A claim, a heartbeat, and a completion each update it.

sql
CREATE TABLE cyclone_steps (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    run_id        bigint NOT NULL REFERENCES cyclone_runs(id),
    node          text NOT NULL,          -- stable node ID within the run
    kind          text NOT NULL,          -- handler kind for dispatch
    state         text NOT NULL DEFAULT 'pending',
    input         jsonb,                  -- handler input (immutable after insert)
    output        jsonb,                  -- handler output (written on completion)
    attempt       int NOT NULL DEFAULT 0, -- failed executions consumed
    max_attempts  int NOT NULL DEFAULT 5,
    error         text,                   -- last error message
    queue         text NOT NULL DEFAULT 'default',
    priority      int NOT NULL DEFAULT 0,
    scheduled_at  timestamptz NOT NULL DEFAULT now(), -- earliest claim time
    lease_expiry  timestamptz,            -- NULL when not running
    lease_owner   text,                   -- client UUID when running
    lease_generation bigint NOT NULL DEFAULT 0,
    retention     interval,               -- how long to keep after terminal
    UNIQUE (run_id, node)
);

The columns above are the hot-path subset. The Schema Reference lists the other columns. Those columns cover waits, errors, uniqueness, timing, and fan-in.

The migration sets these storage parameters:

sql
WITH (autovacuum_vacuum_scale_factor = 0.02,
      autovacuum_vacuum_threshold    = 1000,
      fillfactor                     = 90)

fillfactor = 90 keeps free space on the page for a HOT update. HOT means a heap-only tuple update. A change to an indexed column still needs index maintenance. A change to a partial-index predicate still needs index maintenance. Many state changes and many lease changes are in that group.

cyclone_deps ​

sql
CREATE TABLE cyclone_deps (
    run_id      bigint NOT NULL REFERENCES cyclone_runs(id),
    node        text NOT NULL,  -- dependent node
    dep_node    text NOT NULL,  -- upstream node
    dep_type    text NOT NULL DEFAULT 'control',
    PRIMARY KEY (run_id, node, dep_node)
);

Cyclone inserts these rows at submit time. Cyclone never updates these rows. When a step completes, Cyclone reads cyclone_deps for the downstream steps. The read finds each downstream step whose parents are all in state completed.

cyclone_schedules ​

sql
CREATE TABLE cyclone_schedules (
    id          text PRIMARY KEY,
    kind        text NOT NULL,
    input       jsonb NOT NULL,
    cron_spec   text NOT NULL,                 -- cron expression
    location    text NOT NULL DEFAULT 'Local', -- IANA timezone
    next_run_at timestamptz NOT NULL,
    last_run_id bigint,
    consecutive_failures  int NOT NULL DEFAULT 0,
    enabled     boolean NOT NULL DEFAULT true
);

cyclone_schedule_fires ​

sql
CREATE TABLE cyclone_schedule_fires (
    schedule_id   text NOT NULL REFERENCES cyclone_schedules(id),
    scheduled_for timestamptz NOT NULL, -- the slot that was fired
    run_id        bigint NOT NULL,
    PRIMARY KEY (schedule_id, scheduled_for)
);

The primary key on (schedule_id, scheduled_for) blocks a duplicate fire from a concurrent scanner.

Views ​

Four read-only views support monitoring and debugging. The views are cyclone_v_runs, cyclone_v_steps, cyclone_v_kinds, and cyclone_v_queues. The Schema Reference lists the output columns, including wait counts and panic_stack. The embedded migrations define those views.

Indexes ​

The migrations create these indexes:

TableIndex columnsPurpose
cyclone_steps(queue, priority DESC, scheduled_at) WHERE state = 'available'The claim loop reads this index.
cyclone_steps(lease_expiry) WHERE state = 'running'The recovery sweep reads this index.
cyclone_steps(sequence_key, scheduled_at, id) for active sequenced rowsFIFO gate for an active sequence.
cyclone_runs(state, id DESC)List runs by state.
cyclone_schedules(next_run_at) WHERE enabled = trueThe schedule scanner reads this index.
cyclone_schedule_firesPK (schedule_id, scheduled_for)This key blocks a duplicate fire.

Locks Used ​

In normal work, Cyclone takes only row locks. Cyclone takes an advisory lock only during migration.

  • The claim loop uses SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1. That statement locks one cyclone_steps row. SKIP LOCKED means a worker does not wait behind another worker.
  • A CAS transition uses UPDATE ... WHERE state = 'expected'. The update lock is on one row. Cyclone holds that lock only until the transaction ends.
  • The schedule scanner uses SELECT ... FROM cyclone_schedules FOR UPDATE SKIP LOCKED. Each claim attempt locks one row.
  • Migrate takes an advisory lock. The lock allows only one migration call at a time.

Normal work does not take a table lock.

Transaction Boundaries ​

OperationTransactionContents
EnqueueIts own transactionInserts the run and the step. Cyclone sends NOTIFY after the commit.
EnqueueTxThe caller transactionInserts the run and the step. Cyclone sends no NOTIFY. The next poll sees the row after the caller commits. Or the caller calls PublishWakeups after a TrackWakeups commit.
SubmitIts own transactionInserts the run, the steps, and the deps. Cyclone sends NOTIFY after the commit.
SubmitTxThe caller transactionInserts the run, the steps, and the deps. Cyclone sends no NOTIFY. The next poll sees the rows after the caller commits. Or the caller calls PublishWakeups after a TrackWakeups commit.
EnqueueCompletion / SubmitCompletionThe handler completion transactionThe run insert commits with the step completion. Cyclone sends NOTIFY after that commit.
Claim stepIts own short transactionSets the step state to running and sets the lease.
HeartbeatIts own transaction, one for each tickThe UPDATE checks the generation and sets lease_expiry for one step.
Complete stepIts own transactionSets the step state to completed, stores the output, and includes the ctx.CompletionTx() writes.
Recovery sweepIts own transaction, one for each reclaimSets the step to available and adds 1 to reclaims, or sets the step to discarded past WithMaxReclaims.
Schedule fireIts own transactionInserts the schedule fire, inserts the run, and updates next_run_at.

The completion transaction is the "own transaction" row for "Complete step". Only that transaction includes business writes. The handler does those writes on ctx.CompletionTx(). Every other transaction belongs to the engine.

Isolation Assumptions ​

Cyclone is designed for Read Committed isolation. The tests use Read Committed isolation. Read Committed is the Postgres default. Row locks and CAS guards are the serialization points.

Operational Implications ​

Claims, heartbeats, and completions update cyclone_steps often. You must set autovacuum to do this work often. The migration sets autovacuum_vacuum_scale_factor to 0.02. The migration sets the vacuum threshold to 1000. Those values match this rate of updates.

Dead tuples come from completed rows and from discarded rows. Dead tuples also come from frequent updates. Monitor pg_stat_user_tables.n_dead_tup for cyclone_steps. When the bloat is large, do VACUUM ANALYZE cyclone_steps. Or change the autovacuum parameters.

The claim-loop index serves every worker poll. Monitor index bloat in pg_stat_user_indexes. When you need to rebuild it, do REINDEX CONCURRENTLY idx_steps_claim during off-peak hours.

Each client requires MaxConns >= txSlots + 6. Compare the total for every instance with PostgreSQL max_connections.

Cyclone stores each run in Postgres.