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*.pyfiles of that folder (ls migrations/versions/*.py).alembic.inicarries no database address;migrations/env.pytakes it fromSettingsor 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.shbefore 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):
- An address set by a program in
cfg.attributes["explicit_url"].upgrade_database_to_headalways sets it, so tenant provisioning and the fleet runner reach the intended database (src/cluster_erp/core/tenancy.py:687-692). - Otherwise
Settings().database_url_sync, built from theCLUSTER_ERP_DB_*variables. - If
Settingscannot 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 headon an empty database (.github/workflows/ci.yml:113). - On a single-tenant node,
install.shtakes a backup and runsalembic upgrade head(deploy/install.sh:221-226). - In multi-tenant mode each worker creates the default database if needed and calls
upgrade_database_to_headat start-up (src/cluster_erp/server/app.py:110-113). provision_tenantreplays the history on a new tenant database (src/cluster_erp/server/provision_tenant.py:132-137).upgrade_tenantsupgrades 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.tomlcalls 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 searchingtests/formigrations/versions. alembic checkis not a gate yet. The workflow comment says the comparison reports false differences becauseenv.pyimports 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-runlists 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_schemacreates its tables withcreate_alland adds later columns with idempotentALTER TABLE ... IF NOT EXISTSstatements (src/cluster_erp/core/tenancy.py:387-410). - At every start-up a sweep adds the columns
deactivation_reasonanddeactivation_noteto any table withdeleted_atthat 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.downgradewas found by searchingsrc,deploy, andscripts. install.shtakes 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 |
Related pages¶
Verified against commit 92f8051 on 2026-11-13.