Storage model

Where Flux keeps state — the event log, artifact storage, and workflow catalog — and what's volatile versus durable.

A running Flux cluster has a lot of moving parts: a server, one or more workers, scheduled jobs, and the workflow code itself. State lives in only three places, and once you know which is which, “what survives a crash?” stops being a guessing game.

This page names those three planes, walks each one against the source, and then traces a single workflow run through all of them. It is the prerequisite for any conversation about backups, retention, or migrations.

Three storage planes

Flux persists state across three planes, and only three:

  1. The event log — every state transition of every execution. This is the source of truth that powers replay and crash recovery.
  2. Output storage — the actual return values of tasks and workflows, when those values are large enough to keep out of the event log.
  3. The workflow catalog — the source code and metadata of every registered workflow.

The event log and the catalog live in the same relational database. Output storage is a separate plane with its own backend. Anything not in one of those three places — a running task’s local variables, a worker process’s memory, a half-formed HTTP request — is volatile by design.

Each plane has a different access pattern, a different growth curve, and a different backup story. Treating them as one undifferentiated “Flux database” is the easiest way to misjudge any of them.

Plane A: The event log

Every workflow execution is a sequence of ExecutionEvent rows. The schema is defined in flux/models.py as two related tables:

The events are ordered by their integer id. Replay reads them in that order, so the event log records exactly what happened in this execution, in what order, and what each step produced.

By default, both tables live in SQLite. flux/config.py sets database_url = "sqlite:///.flux/flux.db" as the default — a file under the configured home directory (.flux by default). For production, swap that to a PostgreSQL URL via database_url in flux.toml or via the FLUX_DATABASE_URL environment variable; the database_type field auto-infers from the URL prefix. SQLite gets a few pragmas applied on connect (WAL journal mode, normal synchronous, an mmap region); PostgreSQL gets a pre-pinged connection pool whose size and recycle interval are also config-driven.

The same database also holds smaller satellite tables that travel with the event log: workers and its children (worker_runtimes, worker_resources, worker_packages), schedules, services, secrets (AES-GCM encrypted), and configs. None of these are conceptually part of the event log — they are operator-facing tables that happen to share the same SQLAlchemy Base.metadata.

There is no separate “checkpoint file.” The act of inserting a TASK_COMPLETED row into execution_events is the checkpoint. If the database has the row, the work is durable. If it doesn’t, the work is lost — there is nothing else to fall back on.

The event log grows without bound by default — every task of every execution is a persisted row. An optional retention job ([flux.retention] in configuration: enabled = false by default, retention_days = 30, sweep_interval = 3600, batch_size = 500) deletes terminal executions and their events once they age out. It is off by default so upgrades never silently remove history; production deployments should enable it. See Retention.

Plane B: Output storage

Inputs and outputs are normally stored inline on the executions row (the input and output columns, both PickleType). For small results — a status string, a count, a short list — inline storage is the right default. For large results, it wastes database space, slows queries, and inflates backups.

flux/output_storage.py introduces a side channel for those large results. Tasks and workflows can declare output_storage=... on their decorator, and when the returning value comes back, Flux calls storage.store(reference_id, value) and stores only the returned OutputStorageReferencestorage_type, reference_id, and a metadata dict — back into the event log.

Two backends ship with Flux:

For a workflow’s own return value, the reference_id is set in flux/workflow.py as f"{ctx.workflow_name}_{ctx.execution_id}", so files end up named {workflow_name}_{execution_id}.pkl (or .json). Tasks construct their own reference ids the same way — the reference_id is the only handle the event log holds; the data lives entirely outside it.

OutputStorage is an abstract base class with three methods: store, retrieve, and delete. An S3-backed or GCS-backed implementation is a straightforward subclass. The reference’s metadata records the serializer that was used at write time, so retrieval works even if the cluster’s default serializer is later changed.

The build-side reference for the API is Workflow inputs and outputs.

Plane C: The workflow catalog

The catalog stores the source code of every workflow the server knows about. It lives in the workflows table (WorkflowModel), implemented by DatabaseWorkflowCatalog in flux/catalogs.py.

Each row holds:

Workers do not need a separate filesystem sync. When a worker claims an execution, the server sends it the catalog entry, the worker decodes the source, exec-loads it under a synthetic module name (flux_workflow__<ns>__<name>__v<version>), and caches the compiled module for workers.module_cache_ttl seconds (300 by default). Version bumps invalidate the cache because the synthetic module name includes the version.

Catalog rows are mutated only by flux workflow register and flux workflow delete. They are read on every execution dispatch. For the auto-registration path (workflow.run(...) from an inline script), the workflow registers itself on first call via _ensure_registered — same table, same code path.

Configuration recap

The defaults that govern the three planes all live in flux/config.py:

SettingDefaultPurpose
home.fluxRoot directory for everything stored locally.
database_urlsqlite:///.flux/flux.dbEvent log + catalog + satellite tables.
database_typesqlite (auto-infers postgresql from URL)Picks the SQLAlchemy repository.
local_storage_path.dataSubdirectory under home for LocalFileStorage.
serializerpklpkl (dill) or json for inline and file storage.

Override any of them via environment variable (FLUX_DATABASE_URL, FLUX_LOCAL_STORAGE_PATH, …), via flux.toml, or via [tool.flux] in pyproject.toml. Environment variables take precedence; defaults are applied last.

What is not durable

Anything not in the event log, the output storage, or the catalog is volatile. In particular:

The corollary: every Flux durability guarantee can be expressed as a property of the event log. “Resumable on crash” means the event log has a TASK_COMPLETED for the last successful step. “Idempotent retry” means the event log already contains the events for steps that finished, so replay short-circuits them.

A workflow run, traced through storage

Putting the three planes together, here is what gets written and where during one execution:

  1. Registration. flux workflow register hello.py parses the file, extracts the @workflow functions, and inserts (or upserts) rows into the workflows table. The source code is base64-encoded; the docstring and input schema are pulled out and stored alongside.
  2. Run requested. flux workflow run hello (or workflow.run(...) inline) creates an executions row with state = CREATED, then appends a WORKFLOW_SCHEDULED event to execution_events.
  3. Worker claims. A worker matching the resource and affinity constraints calls POST /workers/{name}/claim/{execution_id}. The server flips the executions.state to CLAIMED, sets worker_name, and appends WORKFLOW_CLAIMED.
  4. Execution begins. The worker decodes the catalog source, loads the module, and runs the workflow function. WORKFLOW_STARTED is appended.
  5. Tasks run. For each task, TASK_STARTED is appended when the task begins, TASK_COMPLETED (with the return value in the value column) when it finishes. Retries, fallbacks, and rollbacks each emit their own event types.
  6. Large outputs land elsewhere. When a task or workflow declares output_storage=..., Flux serializes the value to plane B and records an OutputStorageReference (storage type plus reference id) on the event row instead of the raw value.
  7. Workflow completes. WORKFLOW_COMPLETED is appended; the executions.state flips to COMPLETED; the workflow’s final output is stored on executions.output (inline) or as a reference (if output_storage was set on the workflow).

A resume reads the event log for the execution, fast-forwards through the events that already exist, and only re-enters the workflow at the first task without a TASK_COMPLETED.

Inspecting state

The CLI is the supported access path:

Direct SQL access against the event-log database works (sqlite3 .flux/flux.db or psql against your Postgres) and is occasionally useful for debugging, but the schema is internal — names and types change between releases.

Backups and migrations

Backing up a Flux cluster is backing up its three planes:

Schema migrations are Alembic-managed and ship inside the package (flux/migrations/). On first connect — server, worker, or inline run — Flux brings the database to the latest revision automatically: a fresh database gets the full migration chain, an Alembic-managed one gets any pending migrations, and a legacy pre-Alembic database (tables present but no alembic_version) is stamped at the baseline revision and upgraded in place with data preserved. On PostgreSQL the run is serialized across concurrent replicas by an advisory lock. The flux db upgrade / flux db current / flux db history commands give operators explicit control over the same machinery — see Upgrades and migrations and the flux db reference.

What to remember

Where this shows up