Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions rfcs/0002-zero-downtime-version-upgrades/rfc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
start_date: 2026-03-29
mlflow_issue: https://github.com/mlflow/mlflow/issues/21053
rfc_pr:

# Summary

Add tiered schema verification and an AST-based migration classifier so that MLflow tracking servers can start against an older database schema when all pending migrations are purely additive. This eliminates mandatory downtime for the majority of MLflow releases while preserving the safety guarantees of the existing strict check for destructive migrations.

# Basic example

**Operator checks an upgrade path before deploying:**

```bash
$ mlflow db check-upgrade "postgresql://mlflow:mlflow@db:5432/mlflow"
Current revision: 867495a8f9d4
Target revision: 76601a5f987d
Pending migrations: 3

[+] a1b2c3d4e5f6 (SAFE)
create_table: table=scorers
[+] f6e5d4c3b2a1 (SAFE)
create_index: index=ix_scorers_name
[+] 76601a5f987d (SAFE)
create_table: table=issues

All pending migrations are SAFE. Zero-downtime upgrade is possible.
```

**Rolling deployment with safe migrations (zero downtime):**

1. `mlflow db check-upgrade <db-url>` returns exit code 0
2. Deploy new MLflow server instances via rolling update
3. New servers start successfully against the old schema (Tier 3 auto-compat)
4. Run `mlflow db upgrade <db-url>` as a post-deploy job
5. No downtime occurred

**Manual override for cautious migrations:**

```bash
export MLFLOW_ALLOW_SCHEMA_MISMATCH=true
mlflow server --backend-store-uri "postgresql://..."
# Server starts with a warning instead of crashing
```

## Motivation

MLflow's `_verify_schema()` function performs a strict equality check between the database's current Alembic revision and the revision the running code expects. If there is any mismatch, the server raises `MlflowException` and refuses to start. This design forces a stop-the-world deployment workflow on every release that includes a schema migration:

1. Scale down all tracking server instances
2. Run `mlflow db upgrade`
3. Scale back up

For teams running MLflow as a shared tracking server in production (e.g., on Kubernetes), this means scheduled downtime on every release. The problem is that the vast majority of MLflow migrations are purely additive: creating new tables, adding nullable columns, or building indexes. These operations are fully compatible with the old code still running. A server at revision N can safely serve traffic while the database is at revision N or N+K, as long as the pending migrations only add things the old code doesn't reference.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additive change does not always mean safe. For example, the upgraded server code often includes updated SQL statement that inserts rows with the new column, which fails until the DB revision catches up.

For example, revision 1b5f0d9ad7c1 adds a workspace column to many tables like experiment. Every experiment creation request now includes the workspace column with the default value and will fail without the migration. Some other operations listed under SAFE category has similar failure mode (e.g. skipping nullable=true change causes server logic that insert null value to fail.)

Whether a migration is safe cannot be solely determinied by the database scheme and largely depending on the server logic. I think this narrows down the chance of migrations to be categorized as "SAFE" and "CAUTIOUS" significantly.

Casually, skipping revision will break 'some' features, otherwise we don't need the revision, except index change. Then the trade-off that operators will make is whether or not the feature is critical or not. For example, if the organization doesn't use AI Gateway, it is fine to skip migrations for related tables. I think this flexibilty is useful for many organizations, but we need to rethink the design if we go with this direction. The criteria is not safe or unsafe, but rather breaking particular features is acceptable or not.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that the workspace migration is broken, but I'd push back on using it to disqualify the broader "additive" category. The migration is broken for a specific reason: it adds a nullable=False column with a hardcoded server_default='default' and the new server code immediately writes to it on every INSERT. That's not an additive change in the API-versioning sense — it's a required new field with a polite default. Compare to a REST API: adding X-Workspace: required to every POST is a breaking change; adding X-Workspace: optional, defaults to null isn't. The migration is the first kind, not the second.

Under the current RFC table, add_column (nullable=False) is already CAUTIOUS, not SAFE (line 81), so the literal example wouldn't auto-pass Tier 3 anyway. But the example does highlight that we should tighten what add_column (nullable=True) means. A genuinely additive column looks like this:

  • nullable=True
  • No server_default, or a default that produces NULL
  • New code tolerates NULL on read paths (because that's what existing rows will have)
  • New code doesn't unconditionally require the column on write paths against existing tables

When you do it this way, it's safe in both directions. Old server reading new schema: the column is just there, ignored. New server writing to old schema: as long as the new code doesn't require the column in INSERTs that target rows from the old DB, the migration is structurally compatible.

So I'd like to:

  1. Tighten the SAFE classification of add_column to require nullable=True and no non-null server_default. The presence of a non-null default is a smell that the new code expects a value, which is the API-versioning red flag your example actually demonstrates.
  2. Add a CI check in dev/schema_diff.py that fails any PR adding a migration and server-side code that unconditionally writes the new column. The migration alone can't tell us whether the code is backward-compatible — the code change in the same PR can. This is the missing piece that makes "additive" actually mean "additive in both directions".

With those two changes, the workspace migration would be caught at PR time (because it's nullable=False and the same PR adds the workspace column to required INSERT positions), and the existing safely-additive migrations in MLflow's history (the many add_column nullable=True cases) would still be classified as SAFE. We don't have to throw out the category — we just have to draw the line in the right place.

The category I want to defend is real: the bulk of MLflow's migration history is genuinely backward-compatible additive changes, and forcing every one of them through a downtime window because of one badly-written migration would be the wrong lesson to take from this.

On the per-feature granularity framing: Please correct me if I am wrong, I might be. I spent some time looking at whether that's tractable in MLflow as it exists today, and I think the honest answer is "not without a much larger refactor than this RFC". MLflow has exactly one clean feature toggle that gates a database-touching subsystem (MLFLOW_ENABLE_WORKSPACES). Everything else — AI Gateway, scorers, online scoring, assessments, traces, evaluation datasets, jobs, webhooks — has no server-side enable/disable flag and exists by virtue of being in the code.

The pip install 'mlflow[gateway]' extras pattern looks like it could be a shortcut, but it isn't: the [gateway] and [genai] extras only install runtime provider dependencies (boto3, tiktoken, slowapi, etc.); they don't gate the gateway code itself. mlflow/server/fastapi_app.py does an unconditional from mlflow.server.gateway_api import gateway_router; fastapi_app.include_router(gateway_router) — there is no try/except ImportError and no if MLFLOW_ENABLE_GATEWAY guard. The gateway router is mounted on every MLflow tracking server regardless of which extras are installed, and the migrations live in the core package.

That means the operator-facing decision the per-feature framing wants to enable ("skip migrations for features I don't use") has no reliable input signal: an operator running plain pip install mlflow still has the gateway router exposed, and skipping the gateway migration would leave it broken for any caller who hit it. To make per-feature granularity work, MLflow would need conditional router mounting, conditional ORM model registration, graceful 404s on disabled features, and an explicit feature registry — a backward-incompatible architectural change to the server, not an addition.

I'd rather solve the downtime problem with the RFC's narrower approach, which doesn't depend on any of that. The two designs compose cleanly, and if MLflow grows a real feature-flag layer in a future RFC, mlflow db check-upgrade can be extended to consume it without rewriting any of this RFC's machinery. I've added a paragraph to the Alternatives section of the RFC explaining why per-feature granularity is out of scope for now and noting it as a compatible future direction.


There is currently no mechanism to distinguish a safe additive migration from a breaking destructive one, and no escape hatch to bypass the check when an operator knows the upgrade is safe.

**Who this affects:**

- Any team running MLflow tracking server as a shared service in production
- Platform teams managing MLflow on Kubernetes or similar orchestrators
- Organizations with SLAs that prohibit scheduled downtime for routine upgrades

### Out of scope

- **Online (live) migration execution**: This proposal does not add a system to run migrations while the server is handling traffic. Migrations are still run explicitly via `mlflow db upgrade`.
- **Multi-version server clusters**: This proposal does not support running servers at two different code versions simultaneously for extended periods. The expectation is that rolling deployments complete within a reasonable window.
- **Backward migrations (downgrades)**: The classifier only analyzes the upgrade path. Rollback safety is not addressed.
- **Non-SQL backend stores**: This proposal only applies to the SQLAlchemy-backed tracking store.

## Detailed design

The implementation has four components: a migration safety classifier, a tiered schema verification function, a CLI command for operators, and a developer tool for CI.

### 1. Migration safety classifier

A new module `mlflow/store/db_migrations/migration_classifier.py` provides AST-based static analysis of Alembic migration scripts. It parses the `upgrade()` function of each migration and classifies every Alembic operation call into one of three safety levels:

| Safety level | Meaning | Operations |
|---|---|---|
| **SAFE** | Purely additive; old code is unaffected | `create_table`, `create_index`, `add_column` (nullable), `alter_column` (set nullable=True), `alter_column` (server_default change) |
| **CAUTIOUS** | Likely safe but requires human review | `add_column` (non-nullable), `alter_column` (type change), `drop_constraint`, `create_foreign_key`, `drop_index`, `execute` (raw SQL) |
| **BREAKING** | Destructive; old code will fail | `drop_table`, `drop_column`, `rename_table`, `alter_column` (rename), ORM/data migrations (detected via `session.query` / `op.get_bind`) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do admins judge whether a CAUTIOUS change is safe or not?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honest answer: I don't think CAUTIOUS should exist as a runtime category at all. It's the bucket we put migrations in when we couldn't decide, and asking the operator to decide is asking the person with the least context to make the call. I tried to construct a case where a third bucket genuinely earns its keep, and I couldn't find one.

Walking through what's currently CAUTIOUS in the table (line 81):

  • add_column (nullable=False)BREAKING. This is the workspace failure mode from comment 1. There's no "it depends".
  • alter_column (type change)SAFE for in-family widening (Alembic declares existing_type and type_; AST reads both), BREAKING for narrowing or cross-family.
  • drop_constraintSAFE. Strictly more permissive.
  • create_foreign_keyBREAKING by default. Author can opt into SAFE with an explicit annotation if they're sure old code already enforces the constraint application-side.
  • drop_indexSAFE. Same family as create_index.
  • execute (raw SQL)BREAKING by default, with the annotation escape hatch from comment 2.

Every operation lands cleanly in SAFE or BREAKING. The middle bucket is empty.

The cases I worried might force a third bucket all collapsed:

  • add_column (nullable=True, server_default=non-null) — borderline shape, mechanically detectable, defaults to BREAKING, author opts in with annotation.
  • alter_column (server_default change) where new code depends on the new default — migration is SAFE, the hazard is in the code change in the same PR, caught by the comment-1 PR-diff check.
  • create_index on a huge table causing lock contention — operational concern about running the migration, not about the new server booting against the old DB. Out of scope for Tier 3 auto-compat; deserves its own treatment in a separate RFC.
  • Mixed-op migrations — already handled by the "worst op wins" rule on line 84.

If you can construct a case where CAUTIOUS genuinely earns its keep, I'd love to see it — that would change my mind. But absent one, I'd rather collapse the runtime taxonomy to two states (SAFE / BREAKING) so the operator's decision is always actionable. "SAFE, do a rolling deploy" or "BREAKING, scale down and run the migration" — no third state to interpret, no slow walk to the override flag, no false-confidence accumulation from ignored CAUTIOUS warnings.

The annotation system from another comment is the only place humans intervene, and it's narrow: only op.execute() and the borderline add_column cases require an annotation, and CI validates it. Most migration authors never see it. Most operators never see CAUTIOUS in check-upgrade output because it doesn't exist anymore.


The overall safety of a migration is the worst classification among all its operations.

**Manual overrides**: A dictionary maps specific revision hashes to known safety levels for migrations the AST parser cannot accurately classify (e.g., VARCHAR widening, which the parser sees as a type change but is actually safe on all supported backends).

**Key design decisions:**

- **AST parsing over string matching**: Using Python's `ast` module provides structured analysis of the migration code rather than fragile regex matching. It correctly handles `op.X`, `batch_op.X`, and nested calls.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AST-based categorization might not be always accurate. Incorrectly classifying migration as SAFE can cause pretty bad decision like data collapse. I think we can rely on it at most as a helper and always require human setup. E.g., when a change author generates a new migration, the parser script suggests the category.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to push back on this one, because I think the AST is doing more reliable work than this gives it credit for, and I think the proposed fix (require humans to annotate every migration) is the wrong direction.

First, the empirical case: the workspace migration cited in another comment was written, reviewed, and merged by humans, and it still ships the failure mode that comment is worried about. Adding more human review to the loop wouldn't have caught it — the people who reviewed it were sharp engineers and they still missed the implications. So "rely on humans" isn't actually safer than "rely on a narrow mechanical check"; it's just a different way to be wrong, with worse failure-mode properties (decay into boilerplate, infrequent review, low context). Rails's safety_assured! from the strong_migrations gem is the canonical cautionary tale here — it's routinely sprinkled in just to silence warnings, not because anyone checked.

Second, the technical case: I went through the operations the current RFC marks as ambiguous and tried to construct cases where the AST genuinely can't decide. Almost all of them collapse:

  • alter_column (type change) — Alembic declares existing_type and type_ explicitly; the AST can decide widening vs. narrowing.
  • drop_constraint — strictly more permissive; safe by definition.
  • drop_index — same family as create_index; safe.
  • create_foreign_key — defaults to breaking unless author annotates that old code already enforces it application-side. No inference needed.
  • add_column (nullable=True, server_default=non-null) — the AST can detect this exact shape and classify it as the borderline case it is. Default-to-breaking, author can promote with annotation.

The one operation I couldn't classify mechanically was op.execute(...). The string is opaque, SQL parsing is brittle, and even with perfect parsing the AST can't tell a planned backfill from a foot-gun. That's the one place where the concern is genuinely true, and that's the one place where the classifier should fail-closed: op.execute() defaults to BREAKING, and the migration author can override only with an explicit annotation that CI validates.

So the proposal is:

  1. AST authoritative for everything except op.execute(). The classifier reads the operation and its arguments and classifies. No human input required for the 95%+ of migrations that don't use raw SQL.
  2. op.execute() defaults to BREAKING, with a narrow author-annotation escape hatch validated in CI. The annotation has to include a reason, has to be machine-parseable, and dev/schema_diff.py blocks the merge if it's missing or malformed.
  3. No general "annotate every migration" requirement. Contributors writing normal Alembic ops never see the annotation system. Only the small fraction of migrations that need raw SQL or fall into the borderline add_column shape pay the annotation cost.

This addresses the underlying concern (AST being wrong about something it can't see) without the cost of decaying manual annotations on every migration.

- **Conservative defaults**: Unknown operations default to CAUTIOUS. Missing `upgrade()` functions default to CAUTIOUS. Classifier failures fall back to the existing strict check (Tier 4).
- **ORM detection as BREAKING**: Migrations that use `session.query()` or `op.get_bind()` perform data transformations that depend on model definitions. These are inherently unsafe for online operation.

### 2. Tiered schema verification

The existing `_verify_schema(engine)` function in `mlflow/store/db/utils.py` is replaced with a 4-tier check:

```
Tier 1: Exact match -> pass silently (unchanged behavior)
Tier 2: Env var override -> warn and continue
Tier 3: Auto-compat -> classify pending migrations; continue if all SAFE
Tier 4: Strict check -> raise MlflowException (unchanged behavior)
```

**Tier 1 - Exact match**: If the database revision matches the code's expected revision, the server starts normally. This is identical to current behavior.

**Tier 2 - Environment variable override**: If `MLFLOW_ALLOW_SCHEMA_MISMATCH=true` is set, the server logs a warning and starts regardless of the mismatch. This is an escape hatch for operators who have manually verified compatibility. It works in both directions (DB ahead or behind).

**Tier 3 - Automatic compatibility**: If the database is behind the code (determined by walking the Alembic revision chain) AND all pending migrations are classified as SAFE, the server logs an informational message and starts. If any migration is CAUTIOUS, a warning is logged and the server falls through to Tier 4. If classification fails for any reason, the server falls through to Tier 4.

**Tier 4 - Strict check**: The existing behavior. The server raises `MlflowException` with the current error message instructing the operator to run `mlflow db upgrade`.

A new helper `_is_schema_behind(current_rev, head_revision)` walks the Alembic revision chain to determine directionality. This prevents auto-compat from activating when the database is ahead of the code (which would indicate a downgrade scenario).

### 3. `mlflow db check-upgrade` CLI command

A new subcommand under `mlflow db` that analyzes the upgrade path from the database's current revision to the latest head:

```bash
mlflow db check-upgrade <database-url> [--json]
```

**Human-readable output** lists each pending migration with a safety icon (`+`=safe, `~`=cautious, `!`=breaking), its operations, and any notes.

**JSON output** (`--json`) produces machine-parseable results for CI pipeline integration.

**Exit codes**: 0 = all safe, 1 = cautious migrations present, 2 = breaking migrations present.

This allows operators to pre-check upgrade safety in CI/CD pipelines before deploying.

### 4. `dev/schema_diff.py` operator tool

A standalone script that provides the same analysis without requiring a database connection:

```bash
# With database connection
python dev/schema_diff.py --db-url "postgresql://..."

# Without database connection (revision range only)
python dev/schema_diff.py --from-revision abc123 --to-revision def456

# JSON output for CI
python dev/schema_diff.py --from-revision abc123 --to-revision def456 --json
```

Same exit codes as `mlflow db check-upgrade`. This is intended for CI jobs that validate migration safety as part of the release process.

### 5. Environment variable

`MLFLOW_ALLOW_SCHEMA_MISMATCH` is a new boolean environment variable (default: `false`) registered in `mlflow/environment_variables.py`. When set to `true`, it activates Tier 2 behavior.

### Recommended deployment workflow

```
┌──────────────────────┐
│ mlflow db check-upgrade│
└──────────┬───────────┘
┌─────────────┼─────────────┐
│ │ │
exit 0 exit 1 exit 2
(all SAFE) (CAUTIOUS) (BREAKING)
│ │ │
v v v
Rolling update Review & Scale down →
(zero downtime) decide migrate →
│ │ scale up
v │
mlflow db upgrade │
(post-deploy job) │
v
MLFLOW_ALLOW_SCHEMA_MISMATCH=true
+ rolling update (if acceptable risk)
OR traditional workflow
```

## Drawbacks

- **Maintenance burden of the classifier**: The AST-based classifier must correctly handle all Alembic operation patterns used in MLflow migrations. New operation patterns may require updates to the classifier. The manual overrides dictionary must be maintained as new edge-case migrations are added.

- **False sense of safety**: The classifier makes static judgments about migration safety. It cannot account for all runtime behaviors. For example, a `create_index` on a very large table could cause lock contention in some databases, which is operationally unsafe even though it's structurally additive. The documentation should clearly state that "SAFE" means structurally additive, not guaranteed zero-impact.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation helps but easily be overlooked. I would prefer having more prominant warning that operators will explicitly need to confirm 'yes I know this can cause unavailability".

@PatrickKoss PatrickKoss Apr 6, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Genuinely good question, and the answer changes after the rework. The original RFC had a SAFE category that included grey-area migrations whose safety depended on what the server code did. In that world, "operator must explicitly acknowledge" was a fair safeguard — there were real cases where the operator was the last line of defense.

After tightening SAFE (only truly nullable additive columns), narrowing the AST classifier (fail-closed on op.execute()), and collapsing CAUTIOUS into SAFE/BREAKING, the SAFE bucket is now provably safe — not "we think it's probably fine", but "the AST has verified the operation is structurally additive AND the CI PR-diff check has verified the server code in the same release is backward-compatible with the old schema". There's no risk left for the operator to acknowledge in the way you originally meant.

That said, I still think Tier 3 should be opt-in at the server level, just for a different reason: changing operator-visible behavior on upgrade without consent is a bad pattern, regardless of whether the new behavior is safe. Concretely:

  • Some operators have wrapper scripts or health checks that depend on mlflow server failing fast on schema mismatch. Flipping Tier 3 on by default would silently break those.
  • "This server is participating in zero-downtime upgrades" should be a grep-able fact in the startup args or env config, not an implicit behavior.
  • Platform teams shouldn't get a deploy-semantics change for free when they bump MLflow versions. They should read the release notes and opt in.

So the proposal is mlflow server --allow-zero-downtime-upgrades (or MLFLOW_ALLOW_ZERO_DOWNTIME_UPGRADES=true), off by default. When the flag is off, behavior is identical to today. When it's on and the schema is out of date with all-SAFE pending migrations, the server logs at INFO level: "Schema mismatch detected, all N pending migrations classified as SAFE, starting normally" — informational, not alarmist, because SAFE actually means safe.

I want to be careful not to bake in a "you have been warned" pattern that contradicts the comment above claim that we've actually closed the failure modes.


- **Complexity in the startup path**: The tiered verification adds branching logic to server startup. If the classifier raises an unexpected exception, the fallback to Tier 4 ensures safety, but the added code paths increase the surface area for bugs.

- **Environment variable as escape hatch**: `MLFLOW_ALLOW_SCHEMA_MISMATCH=true` bypasses all safety checks. If operators set this permanently and forget about it, they could run into data corruption from genuinely breaking migrations. The documentation should emphasize this is a temporary override, not a permanent setting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This risk requires more mitigation than documentation. Instead of a flag, shall we provide a more explicit configuration (e.g. max revision, allowlist)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the boolean override is too blunt for the original RFC's design. The reason it felt blunt was that it was trying to do two jobs at once: "approve a CAUTIOUS migration I've reviewed" AND "bypass a hard mismatch in an emergency". Once CAUTIOUS goes away (reworked), those two jobs separate cleanly, and I think the right answer is one bypass mechanism, not an allowlist plus a boolean.

Concretely:

  • Routine zero-downtime upgrade. Operator runs mlflow db check-upgrade, sees all-SAFE, deploys with --allow-zero-downtime-upgrades. No bypass involved. Tier 3 just works because the classifier has proven the migrations are safe.
  • Emergency bypass. Production is down, operator needs to roll forward against an old DB, at least one pending migration is BREAKING. They set MLFLOW_BYPASS_SCHEMA_CHECK_DANGEROUS=true (the name is intentionally uncomfortable to type). The server logs an ERROR at startup listing every migration being skipped, and an ERROR on every incoming request, so leaving it on accidentally creates a paper trail nobody can ignore.

I considered the allowlist mechanism, and I think it's solving a problem that disappears once CAUTIOUS is gone. Its purpose was "let the operator selectively approve specific revisions whose safety is judgment-dependent". After the rework, there are no judgment-dependent revisions — each pending migration is either provably SAFE or provably BREAKING (only crossable via the emergency bypass). The allowlist would have nothing to selectively allow.

The one case I can imagine wanting an allowlist for is "this migration is BREAKING per the classifier but I, the operator, know it's safe for my deployment because I don't use the affected feature". That's the feature-granularity case from the other comment, and per the analysis there, MLflow doesn't have the infrastructure (feature flags, conditional router mounting) to make that judgment reliable today (again correct me if I am wrong). If the operator wants to override anyway, the emergency bypass is the right tool — one loud escape hatch, not a routine-looking selective-approval mechanism.

I want the bypass to feel like a fire extinguisher: visible, ugly, present for emergencies, painful to leave engaged.


# Alternatives

### 1. Environment variable only (no classifier)

Add only `MLFLOW_ALLOW_SCHEMA_MISMATCH` and let operators decide. Simpler to implement but puts the full burden of migration analysis on the operator. Rejected because the classifier is the high-value component that makes this feature practical.

### 2. Schema version tolerance window

Allow the server to start if the database is within N revisions of the expected version, regardless of migration content. Simple but unsafe: a single breaking migration within the tolerance window would cause failures.

### 3. Maintain a manual safety manifest

Instead of AST parsing, maintain a YAML/JSON file that maps each migration revision to its safety level. More explicit but creates a maintenance burden on every migration and risks going stale. The AST parser automates this while the manual overrides dictionary handles edge cases.

### 4. Database-level compatibility views

Create SQL views that present the old schema shape to old code while the new schema evolves underneath. Powerful but extremely complex, database-specific, and far beyond the scope of MLflow's current architecture.

### 5. Do nothing

Operators continue using the stop-the-world workflow. This is the status quo and works, but it forces unnecessary downtime for the majority of releases that only include additive migrations.

# Adoption strategy

This is a fully backward-compatible change. Existing users experience zero difference unless they opt in:

- **No changes to default behavior**: Tier 1 (exact match) and Tier 4 (strict check) are identical to current behavior. The only new automatic behavior is Tier 3, which only activates when all pending migrations are classified as SAFE.
- **Opt-in escape hatch**: `MLFLOW_ALLOW_SCHEMA_MISMATCH` is `false` by default.
- **New CLI command**: `mlflow db check-upgrade` is additive and does not affect existing commands.
- **Documentation**: The self-hosting migration guide is updated with a "Zero-Downtime Upgrades" section documenting the new workflow, safety levels, and the environment variable.

For teams already running MLflow in production, adoption is straightforward:

1. Add `mlflow db check-upgrade` to CI/CD pipeline
2. If exit code is 0, switch to rolling deployment
3. If exit code is 1 or 2, use traditional workflow

# Open questions

1. **Should CAUTIOUS migrations auto-allow with the env var, or require a separate flag?** Currently, `MLFLOW_ALLOW_SCHEMA_MISMATCH=true` overrides everything (Tier 2). Should there be a more granular `MLFLOW_ALLOW_CAUTIOUS_MIGRATIONS=true` that only bypasses cautious migrations while still blocking breaking ones?

2. **Should the classifier run at import time or lazily?** Currently it runs on-demand during `_verify_schema()`. If classification is slow for repositories with many migrations, it could be pre-computed and cached.

3. **How should the manual overrides dictionary be maintained?** Currently it's hardcoded in the classifier module. Should it be extracted to a separate configuration file that's easier to review and update during migration development?

4. **Should there be a migration safety annotation for migration authors?** A decorator or comment convention (e.g., `# safety: safe`) that migration authors can add to explicitly declare safety, reducing reliance on AST inference.

5. **Index creation on large tables**: Should `create_index` be classified as CAUTIOUS instead of SAFE, given that it can cause lock contention on large tables in some databases? Or should this be database-engine-specific?