Factorizer/Cluster ERP WikiCluster ERP Türkçe
Home / Operations

Explanation

Database migrations

How Cluster ERP changes its database schema through one Alembic history, who runs it, the boot migration lock, and the rule that shipped migrations are frozen.

Every business table of Cluster ERP comes from one Alembic history in migrations/. The same history builds a new tenant database, upgrades an existing one, and is checked in continuous integration. This page explains who runs it, how parallel workers are kept apart, and which parts of the schema are not in that history. The operator commands that run it for many tenants are in tenant lifecycle.

In short

  • migrations/versions/ holds 218 revision files, counted as the *.py files of that folder (ls migrations/versions/*.py).
  • alembic.ini carries no database address; migrations/env.py takes it from Settings or from an address that the caller passes in (migrations/env.py:42-73).
  • All runs go through upgrade_database_to_head, which holds a PostgreSQL advisory lock for the duration of the upgrade (src/cluster_erp/core/tenancy.py:665-720).
  • A multi-tenant worker migrates its default database at start-up; a single-tenant node is migrated by install.sh before the restart (src/cluster_erp/server/app.py:99-113, deploy/install.sh:221-226).
  • Continuous integration applies the whole history to an empty PostgreSQL 16 database and requires exactly one head (.github/workflows/ci.yml:110-117).

How it works

Where the address comes from

migrations/env.py decides the target database in this order (migrations/env.py:56-73):

  1. An address set by a program in cfg.attributes["explicit_url"]. upgrade_database_to_head always sets it, so tenant provisioning and the fleet runner reach the intended database (src/cluster_erp/core/tenancy.py:687-692).
  2. Otherwise Settings().database_url_sync, built from the CLUSTER_ERP_DB_* variables.
  3. If Settings cannot be built, the run stops with an error instead of guessing a database.

The alembic.ini logging section applies only when no logging is configured yet, so an in-process run does not silence the application log (migrations/env.py:32-40). env.py imports each module's top-level models.py to fill target_metadata (migrations/env.py:76-86).

Who runs the history

The diagram shows every caller of the history.

flowchart LR
  ci["CI: alembic upgrade head"] --> alembic["Alembic command.upgrade"]
  inst["install.sh single-tenant"] --> alembic
  boot["worker lifespan multi-tenant"] --> uh["upgrade_database_to_head"]
  prov["provision_tenant"] --> uh
  upg["upgrade_tenants"] --> uh
  uh --> lock["migration_lock"]
  lock --> alembic

In prose:

  • Continuous integration runs python -m alembic upgrade head on an empty database (.github/workflows/ci.yml:113).
  • On a single-tenant node, install.sh takes a backup and runs alembic upgrade head (deploy/install.sh:221-226).
  • In multi-tenant mode each worker creates the default database if needed and calls upgrade_database_to_head at start-up (src/cluster_erp/server/app.py:110-113).
  • provision_tenant replays the history on a new tenant database (src/cluster_erp/server/provision_tenant.py:132-137).
  • upgrade_tenants upgrades every tenant whose revision differs from the head (src/cluster_erp/server/upgrade_tenants.py:83-100).

The boot migration lock

upgrade_database_to_head wraps command.upgrade(cfg, "head") in migration_lock. The lock is pg_advisory_lock with the fixed key MIGRATION_LOCK_KEY, taken on the target database and released at the end (src/cluster_erp/core/tenancy.py:693-720). A second worker waits until the first has finished and then finds the head already applied (src/cluster_erp/core/tenancy.py:693-699). The test tests/test_boot_migration_lock.py checks that a second holder waits for the first.

Why it is built this way

  • One history keeps a provisioned tenant identical to a migrated deployment (src/cluster_erp/core/tenancy.py:666-672).
  • The database address is not stored in alembic.ini, because "a tracked credential is a liability" (migrations/env.py:60-63).
  • The lock exists because two workers once replayed a new revision at the same moment and one failed to boot (src/cluster_erp/core/tenancy.py:693-698).
  • The project's written engineering rules state that a module does not call create_all; the schema comes from Alembic migrations.

Rules and boundaries

  • Shipped migrations are frozen: pyproject.toml calls them "frozen historical records (never edited after they ship)" and exempts them from the line-length rule only (pyproject.toml:134-136). No test that enforces the rule was found by searching tests/ for migrations/versions.
  • alembic check is not a gate yet. The workflow comment says the comparison reports false differences because env.py imports only top-level model files (.github/workflows/ci.yml:118-123).
  • A migration revision is applied only when the target is behind the head; upgrade_tenants --dry-run lists each database's current and head revision without changes (src/cluster_erp/server/upgrade_tenants.py:84-97).

Interaction with other parts

  • The control-plane database has no Alembic history. TenantRegistry.ensure_schema creates its tables with create_all and adds later columns with idempotent ALTER TABLE ... IF NOT EXISTS statements (src/cluster_erp/core/tenancy.py:387-410).
  • At every start-up a sweep adds the columns deactivation_reason and deactivation_note to any table with deleted_at that lacks them, under its own transaction advisory lock (src/cluster_erp/server/app.py:1111-1166). See soft delete and deactivation.
  • A release also refreshes seeds after the schema step; see tenant lifecycle.

Limits and open points

  • Downgrade of a release: Not determined from code. No call of command.downgrade was found by searching src, deploy, and scripts.
  • install.sh takes a backup before it migrates, in single-tenant and in multi-tenant mode (deploy/install.sh:214-226). See backups and restore.

Terms introduced

Term Code identifier Definition Turkish term Source
head revision get_current_head The newest revision of the Alembic history. baş revizyon src/cluster_erp/server/upgrade_tenants.py:42-44
explicit URL explicit_url The database address a program passes to Alembic; it wins over the settings. açık adres migrations/env.py:56-58

Verified against commit 92f8051 on 2026-11-13.

Verified against commit 92f8051 · 2026-11-13