Skip to content

Data model ​

Postgres is the server's only stateful dependency. It holds the graphs, the run history, the locks, the audit log and the work queue. Nothing in it is needed to operate Terraform: wiping the database loses history and locks, never state.

All timestamps are UTC timestamptz. Stackorder's own ids (runs.id, stacks.id, modules.id, graphs.id, dispatches.id) are UUIDs; installations.id and repos.id are GitHub's numeric ids. The schema is created by the migrations in migrations/, 0001_graph to 0006_run_stack_via.

Tables ​

Graph ​

TableKey columnsNotes
installationsid, account, account_type, suspended_atOne per GitHub App installation
reposid, installation_id, full_name (unique), default_branch, config (jsonb), config_sha, private, default_graph_idconfig is the parsed stackorder.yaml of the default branch at config_sha; default_graph_id is the graph of the last merged pull request
graphsid, repo_id, sha, tree_hash, warnings, external_stacks, created_atOne row per repository and commit (UNIQUE (repo_id, sha)); tree_hash enables cache hits
stacksid, repo_id, key, path, workspace, backend (jsonb), environment, tool, config (jsonb), first_seen_at, last_seen_at, removed_atStable identity per repository and key (UNIQUE (repo_id, key)), so history survives commits. key is path or path:instance, so each instance of a directory has its own row, and the instance is split off the key rather than stored in a column; workspace is the Terraform workspace the CLI selects, empty for none. backend holds the effective S3 bucket, key, region and lock settings, after backend_config
graph_stacksgraph_id, stack_id, node (jsonb)Which stacks a graph has, with the stack node as scanned, including its instance and watch_paths
modulesid, source_key (unique), base_key, kind (local, git, registry), source, path, refOne row per pinned module (key@ref) and one ref-less family row per base_key, which versions attach to
graph_modulesgraph_id, module_id, node (jsonb)Which modules a graph has
edgesgraph_id, from_kind, from_key, to_kind, to_key, type, inferred, meta (jsonb)Keys rather than ids, so an edge can point at a stack in another repository; meta holds ref and source for module edges, bucket and key for reads_state
module_versionsmodule_id, version, sha, tagged_atSemver tags pushed to module repositories

Runs ​

TableKey columnsNotes
runsid, repo_id, sha, base_sha, pr_number, trigger, mode, status, requested_by, graph_id, waves, current_wave, warnings, workflow_run_id, workflow_run_attempt, check_runs (jsonb), created_at, started_at, finished_attrigger is pull_request, comment, push, schedule, rerequest or manual; check_runs records the GitHub check run ids the server created
run_stacksrun_id, stack_id, wave, mode, status, reasons, via, environment, adds, changes, destroys, replaces, has_changes, exit_code, job_url, plan_artifact, plan_run_id, summary (jsonb), plan_text, plan_text_truncated, plan_url, plan_output, error_text, blocked_by, dispatch_id, started_at, finished_at, updated_atOne row per stack per run; the observability core. via lists the modules or stacks the change reached the stack through, as the graph replay shows it. plan_text is capped at 256 KB, or 8 KB with the artifact bucket
checksrun_id, stack_id, name, status, summary, details, details_url, updated_atNamed check verdicts, one per stack, run and name
dispatchesid, run_id, wave, environment, mode, chunk, workflow_run_id, dispatched_at, sent_at, completed_at, conclusionOne row per workflow_dispatch, unique per (run, wave, environment, mode, chunk); a workflow run id belongs to at most one dispatch
locksstack_id, run_id, pr_number, taken_at, reasonPrimary key on stack_id, so a lock is unique by construction
driftid, stack_id, run_id, checked_at, drifted, summary (jsonb), issue_numberOne row per drift check; the latest per stack is what the UI shows

Queue and access ​

TableKey columnsNotes
eventsid (the delivery id), kind, payload, received_at, claimed_by, claimed_at, attempts, run_after, done_at, last_errorWebhook deliveries; the delivery id as primary key deduplicates redeliveries
jobsid, kind, payload, dedupe_key (unique), run_after, claimed_by, claimed_at, attempts, done_at, last_error, created_atScheduled and internal work
oidc_jtisjti, expires_atRunner token ids already accepted, so a token is used once
sessionsid, login, user_id, avatar_url, orgs, created_at, expires_atid is the SHA-256 of the session token
api_keysid, name, key_hash (unique), prefix, created_by, created_at, last_used_at, revoked_atkey_hash is the SHA-256 of the key, prefix its first 10 characters
auditid, at, actor, action, target, details (jsonb)Unlocks, re-runs, manual applies, commands and other audited actions

golang-migrate adds its own schema_migrations table.

Edges point at stacks and modules by kind and key, so they are not drawn as foreign keys above.

The queue ​

events and jobs replace a message queue. Webhooks are written to events and acknowledged at once. Workers claim rows with SELECT ... FOR UPDATE SKIP LOCKED, and every handler is idempotent, so the work of a crashed instance is simply claimed again: a claim older than 10 minutes is released, and a handler runs at most 5 minutes. attempts and run_after drive retries after 1 min, 5 min, 30 min and 2 h; after 5 attempts a row is a dead letter. A job's dedupe_key stays taken until the job is pruned, so recurring jobs put a time slot in their key.

Size ​

A 300-stack monorepo with 2,000 edges is well under a megabyte per graph. The resolve job sends a tree hash of the paths it scanned, so a re-run on the same tree is a cache hit and stores nothing new.

Plan text is cut at 256 KB by the CLI. With STACKORDER_ARTIFACT_BUCKET set, the full text goes to that bucket and Postgres keeps its first 8 KB; see Artifact bucket.

Retention ​

An hourly prune job applies the retention settings:

DataKeptSetting
Plan text in run_stacks30 days after the row last changed; the summary staysSTACKORDER_PLAN_TEXT_RETENTION, default 720h
Webhook events7 days after they were received, once done or never claimedSTACKORDER_EVENT_RETENTION, default 168h
Finished jobs7 days after they finishedSTACKORDER_EVENT_RETENTION
Drift history90 days, always keeping the latest result of each stackSTACKORDER_DRIFT_RETENTION, default 2160h
Accepted runner token idsUntil the token expires
SessionsUntil they expire, 7 days after sign-in
LocksUntil released
Runs, stack rows, summaries, checks, graphs, API keys, audit logIndefinitely

Migrations ​

Migrations are golang-migrate SQL files, NNNN_name.up.sql and NNNN_name.down.sql, embedded in the binary. The server runs them at start-up under a Postgres advisory lock, so several instances starting together apply each migration once, and it refuses to start if a migration fails. See Upgrades and backups.