Skip to content

Conversation

@thromel
Copy link
Contributor

@thromel thromel commented Dec 23, 2025

Summary

Implements #37342: Allow creating and applying migrations at runtime without recompiling.

This adds support for creating and applying migrations at runtime using Roslyn compilation, enabling scenarios like .NET Aspire and containerized applications where recompilation isn't possible.

CLI Usage

# Standard update (existing behavior)
dotnet ef database update [migration]

# Create and apply a new migration in one step
dotnet ef database update MigrationName --add [--output-dir <DIR>] [--namespace <NS>] [--json]

The -o/--output-dir, -n/--namespace, and --json options require --add to be specified.

PowerShell Usage

# Standard update (existing behavior)
Update-Database [-Migration <migration>]

# Create and apply a new migration in one step
Update-Database -Migration MigrationName -Add [-OutputDir <DIR>] [-Namespace <NS>]

Architecture

Component Purpose
IMigrationCompiler / CSharpMigrationCompiler Internal: Roslyn-based compilation of scaffolded migrations
IMigrationsAssembly.AddMigrations(Assembly) Registers dynamically compiled migrations
MigrationsOperations.AddAndApplyMigration() Orchestrates scaffold → compile → register → apply workflow

Design Decisions

  • Extends existing services: Uses IMigrationsScaffolder for scaffolding and IMigrator for applying, adding only the new IMigrationCompiler service
  • AddMigrations(Assembly): Extended IMigrationsAssembly interface to accept additional assemblies containing runtime-compiled migrations
  • Always persists to disk: Like AddMigration, files are always saved to enable source control and future recompilation
  • No pending changes behavior: If no model changes are detected, applies any existing pending migrations without creating a new one
  • Internal compiler API: IMigrationCompiler and CSharpMigrationCompiler are in the .Internal namespace as they require design work for public API
  • Error handling with cleanup: If compilation or migration application fails, saved migration files are cleaned up to prevent orphans
  • Thread safety: MigrationsAssembly uses locking to protect against race conditions when adding migrations concurrently

Workflow

User runs: dotnet ef database update InitialCreate --add
    │
    ▼
MigrationsOperations.AddAndApplyMigration()
    │
    ├─► Check for pending model changes
    │       └─► If none: apply existing migrations, return
    │
    ├─► IMigrationsScaffolder.ScaffoldMigration() - Generate code
    │
    ├─► try {
    │       ├─► IMigrationsScaffolder.Save() - Write files to disk
    │       ├─► IMigrationCompiler.CompileMigration() - Roslyn compile
    │       ├─► IMigrationsAssembly.AddMigrations() - Register migration
    │       └─► IMigrator.Migrate() - Apply to database
    │   } catch {
    │       └─► Clean up saved files on failure
    │   }
    │
    └─► Return migration files

Robustness Features

  1. Exception handling with cleanup: AddAndApplyMigration wraps the save-compile-register-apply chain in try-catch, deleting saved files on failure to prevent orphans
  2. Context disposal on validation failure: PrepareForMigration ensures the DbContext is disposed if validation or service building fails
  3. Thread-safe migration registration: MigrationsAssembly uses locking to protect shared state (migrations dictionary, model snapshot, additional assemblies list)

Limitations

  • Requires dynamic code generation (incompatible with NativeAOT) - marked with [RequiresDynamicCode]
  • C# only (no VB.NET/F# support)

Test plan

  • Unit tests for CSharpMigrationCompiler
  • Unit tests for MigrationsOperations.AddAndApplyMigration
  • Integration tests in RuntimeMigrationTestBase (SQLite and SQL Server implementations)
  • Tests for validation (empty name, invalid characters)
  • Tests for RemoveMigration with dynamically created migrations
  • All existing EFCore.Design.Tests pass
  • All existing EFCore.Relational.Tests pass

Fixes #37342

@thromel thromel force-pushed the feature/runtime-migrations branch from a15611a to 9a35a9b Compare December 23, 2025 20:45
@AndriySvyryd AndriySvyryd self-assigned this Dec 23, 2025
@thromel thromel marked this pull request as ready for review December 24, 2025 06:22
@thromel thromel requested a review from a team as a code owner December 24, 2025 06:22
@thromel thromel marked this pull request as draft December 25, 2025 02:03
@thromel thromel force-pushed the feature/runtime-migrations branch from 62018f1 to 5c41f2f Compare December 25, 2025 03:51
@thromel
Copy link
Contributor Author

thromel commented Dec 25, 2025

Note on SQL Server Integration Tests

The RuntimeMigrationSqlServerTest tests are marked with [SqlServerCondition(SqlServerCondition.IsNotAzureSql | SqlServerCondition.IsNotCI)] and are skipped in CI. This follows the same pattern used by MigrationsInfrastructureSqlServerTest.

Why these tests are skipped in CI:

  • They require creating fresh databases dynamically for each test to properly test the migration flow from scratch
  • The Helix CI environment has SQL Server available but with limited permissions configured for shared/pre-configured databases
  • Tests that use SqlServerTestStore.CreateInitializedAsync with dynamic database names don't work in the CI SQL Server setup

Test coverage is still maintained:

  • Core runtime migration logic is covered by unit tests in EFCore.Design.Tests (which run in CI)
  • The SQL Server integration tests run locally for developers with SQL Server configured
  • SQLite integration tests in EFCore.Sqlite.FunctionalTests also validate the end-to-end flow

This is consistent with how other complex migration infrastructure tests handle CI limitations.

@thromel thromel marked this pull request as ready for review December 25, 2025 08:13
@thromel thromel marked this pull request as draft December 25, 2025 16:43
@thromel thromel marked this pull request as ready for review December 25, 2025 21:07
@thromel
Copy link
Contributor Author

thromel commented Dec 31, 2025

Thank you for the thorough review @AndriySvyryd! I've addressed all your feedback:

  1. SQL Server tests: Now using static database name "RuntimeMigrationTest" and removed EnsureDeleted() calls since CreateInitializedAsync already cleans the database.

  2. CLI validation: Added validation that shows an error if -o or -n is used without --add.

  3. Renamed to AddAndApplyMigration: Renamed CreateAndApplyMigration to AddAndApplyMigration and reordered parameters as suggested.

  4. Extracted common validation: Created ValidateMigrationName() and ValidateMigrationNameNotContextName() helper methods that are shared by both AddMigration and AddAndApplyMigration.

  5. Added EnsureMigrationsAssembly call: Now calling EnsureMigrationsAssembly in AddAndApplyMigration.

  6. Removed unnecessary IDesignTimeModel registration: Confirmed there's no duplicate registration.

  7. Merged IDynamicMigrationsAssembly into IMigrationsAssembly: Added AddMigrations(Assembly) method to IMigrationsAssembly and deleted IDynamicMigrationsAssembly, DynamicMigrationsAssembly, and their tests.

  8. Replaced CompiledMigration with AddMigrations(Assembly): IMigrationCompiler.CompileMigration now returns Assembly directly, and RuntimeMigrationService uses _migrationsAssembly.AddMigrations() to register compiled migrations. Removed the CompiledMigration class.

All tests pass locally (SQLite: 26 tests, SQL Server: 7 tests, CSharpMigrationCompiler: 4 tests, MigrationsOperations: 2 tests).

@thromel thromel requested a review from AndriySvyryd December 31, 2025 05:21
Comment on lines 300 to 301
OutputDirectory = outputDir,
Namespace = @namespace,
Copy link
Member

Choose a reason for hiding this comment

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

Create default values for OutputDirectory and Namespace if not specified, like in AddMigration above. In general, try combine this and AddMigration into a single method.

/// See <see href="https://aka.ms/efcore-docs-migrations">Database migrations</see> for more information and examples.
/// </para>
/// </remarks>
public class RuntimeMigrationService : IRuntimeMigrationService
Copy link
Member

Choose a reason for hiding this comment

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

Don't create a new service for this, distribute the functionality among the existing services like IMigrationsScaffolder and IMigrator,

@thromel
Copy link
Contributor Author

thromel commented Jan 3, 2026

Addressed the additional review feedback:

  1. Moved validation to Validate() - DatabaseUpdateCommand now uses Validate() override instead of validating in Execute()

  2. Removed extension methods - CreateAndApplyMigration and CreateAndApplyMigrationAsync removed from RelationalDatabaseFacadeExtensions

  3. Removed PersistToDisk - Files are always persisted to disk (like AddMigration)

  4. Using defaults like AddMigration - AddAndApplyMigration now uses SubnamespaceFromOutputPath() for namespace defaults

  5. Distributed RuntimeMigrationService to existing services:

    • Deleted IRuntimeMigrationService, RuntimeMigrationOptions, RuntimeMigrationResult
    • Deleted RuntimeMigrationService
    • Moved functionality into MigrationsOperations.AddAndApplyMigration()
    • Uses existing IMigrationsScaffolder, IMigrationCompiler, IMigrationsAssembly, and IMigrator
  6. No longer throws if no pending changes - Instead logs an info message and applies existing migrations

Files changed: 11 files, +132/-2064 lines (significant cleanup)

thromel added 25 commits January 8, 2026 10:39
In Helix distributed testing, PIPELINE_WORKSPACE is not set.
Add HELIX_CORRELATION_PAYLOAD check so tests marked with
[SqlServerCondition(IsNotCI)] are properly skipped in Helix.
New SQLite integration tests:
- Can_apply_multiple_sequential_migrations: Apply 2 migrations in sequence
- Can_create_migration_with_indexes_and_foreign_keys: Complex model with FK/indexes
- Generated_migration_file_has_correct_structure: Verify file contents
- Down_migration_reverses_changes: Verify rollback works

New CLI/OperationExecutor tests:
- CreateAndApplyMigration_errors_for_bad_names: Invalid migration name handling
- CreateAndApplyMigration_errors_for_empty_name: Empty name validation
- Add RevertMigration and RevertMigrationAsync methods to
  IRuntimeMigrationService interface
- Implement revert functionality in RuntimeMigrationService that:
  - Tracks applied dynamic migrations
  - Executes Down operations to undo changes
  - Removes migration from history table
- Add error handling for missing/not-found migrations
- Fix tests to properly verify actual rollback behavior instead of
  just checking generated code
- Add tests for error cases and async revert
- Add partial failure tests (table conflict, external interference)
- Add error recovery tests (revert after manual drop)
- Add dry run safety verification
- Add migration tracking and result verification tests
- Add edge case tests for special characters and null paths
- Total: 26 SQLite functional tests, 20 design tests (46 total)
Testing for transient CI failures.
Add validation for invalid file name characters and context name
conflicts to the CreateAndApplyMigration operation, matching the
existing validation in the AddMigration operation. This fixes the
Windows CI test failure where tests for bad migration names were
expecting proper error handling.
- Rename CreateAndApplyMigration to AddAndApplyMigration
- Add CLI validation: error if -o or -n used without --add
- Extract common migration name validation into helper methods
- Merge IDynamicMigrationsAssembly into IMigrationsAssembly
- Replace CompiledMigration with AddMigrations(Assembly) API
- Fix SQL Server tests: use static DB name, remove EnsureDeleted
- Remove CompiledMigration, IDynamicMigrationsAssembly, DynamicMigrationsAssembly
- Move validation to Validate() override in DatabaseUpdateCommand
- Remove CreateAndApplyMigration extension methods from RelationalDatabaseFacadeExtensions
- Delete IRuntimeMigrationService, RuntimeMigrationOptions, RuntimeMigrationResult
- Delete RuntimeMigrationService - functionality moved to MigrationsOperations
- Refactor AddAndApplyMigration to:
  - Use SubnamespaceFromOutputPath for defaults (like AddMigration)
  - Skip migration creation if no pending model changes
  - Return MigrationFiles instead of RuntimeMigrationResult
- Add RevertMigration method to MigrationsOperations
- Remove functional tests that depended on deleted API
Add tests for scaffolding, compiling, registering, and applying
migrations at runtime. These tests run for all providers that
implement DesignTimeTestBase (SQLite and SQL Server).
Add tests that were missing from the original implementation:
- HasPendingModelChanges_returns_false_after_migration
- Compiled_migration_contains_correct_operations
- Can_scaffold_and_save_migration_to_disk
Add 21 new tests across multiple test files:

CSharpMigrationCompilerTest.cs (5 tests):
- CompileMigration_throws_on_null_migration
- CompileMigration_throws_on_null_context_type
- CompileMigration_throws_on_empty_migration_code
- CompileMigration_handles_unicode_in_migration_name
- CompileMigration_throws_on_very_long_migration_name

MigrationsOperationsTest.cs (4 tests):
- AddMigration_validates_migration_name_with_invalid_characters
- AddMigration_rejects_context_class_name
- RevertMigration_throws_when_no_dynamic_migrations
- RevertMigration_throws_when_specifying_migration_id_with_empty_list

DesignTimeTestBase.cs (5 tests):
- Can_apply_multiple_migrations_sequentially
- Migration_up_and_down_operations_are_symmetric
- Can_revert_migration_using_down_operations
- Applied_migration_is_recorded_in_history
- Compiled_migration_has_matching_up_and_down_table_operations
- Add empty name validation to CreateAndApplyMigration
- Create separate RuntimeMigrationTestBase.cs for runtime migration tests
- Create RuntimeMigrationSqliteTest.cs with proper test isolation
- Revert DesignTimeTestBase.cs to original state (only 2 tests)
- Use fixed database name with sequential test execution to prevent conflicts
- Add GetDatabaseModel helper using IDatabaseModelFactory for schema introspection
- Add ScaffoldAndApplyMigration helper to reduce boilerplate
- Add 9 new rigorous tests that verify actual database schema:
  - Migration_creates_correct_table_structure
  - Migration_creates_correct_primary_keys
  - Migration_creates_correct_foreign_keys (with cascade delete verification)
  - Migration_creates_columns_with_correct_constraints
  - Migration_down_removes_schema_completely
  - Migration_creates_foreign_key_index
  - Migration_with_no_changes_produces_empty_operations
  - Migration_preserves_existing_data
  - Applied_migration_snapshot_matches_model

Total: 22 runtime migration tests now verify scaffolding, compilation,
application, schema structure, rollback, and data preservation.
EF Core's compliance test requires all test base classes to have
implementations for each provider. This adds the SQL Server
implementation of RuntimeMigrationTestBase to fix the CI failure.
Clear SQLite connection pools before deleting database to release
file locks. This prevents "file in use" errors on Windows CI.
- CLI: Make --add a switch, use <MIGRATION> argument for name
- CLI: Add --json option for machine-readable migration output
- CLI: Add validation requiring migration name when using --add
- PowerShell: Add -Add, -OutputDir, -Namespace params to Update-Database
- Remove RevertMigration method and _appliedDynamicMigrationIds tracking
- Move IMigrationCompiler/CSharpMigrationCompiler to Internal namespace
- Update tests: use table cleanup instead of database deletion
- Update tests: assert specific exception messages
- Extract PrepareForMigration helper to consolidate common validation
  between AddMigration and AddAndApplyMigration methods
- Simplify CSharpMigrationCompiler by removing arbitrary assembly prefix
  filtering - now includes all non-dynamic assemblies as reviewer suggested
- Add RemoveMigration_removes_dynamically_created_migration test that
  exercises the full lifecycle: scaffold -> save -> compile -> register
  -> apply -> revert -> remove
Per PR review comment 2663355462: Move the Validate() method from
DatabaseUpdateCommand.cs to DatabaseUpdateCommand.Configure.cs so
the validation is also included in the dotnet-ef tool.

Added required resource strings to dotnet-ef:
- MissingArgument
- OutputDirRequiresAdd
- NamespaceRequiresAdd
Override CleanDatabase in RuntimeMigrationSqlServerTest to properly
handle foreign key constraints. SQL Server requires dropping FK
constraints before dropping tables, unlike SQLite which handles this
automatically with DROP TABLE IF EXISTS.

The fix:
1. Drops all foreign key constraints first using dynamic SQL
2. Then drops all tables
3. Finally drops the migrations history table
Properly restore connection state after cleaning database tables.
The connection is closed after cleanup only if it wasn't already
open before, preventing "connection was not closed" errors in tests
that expect to open the connection themselves.
1. AddAndApplyMigration error handling:
   - Add try-catch around scaffold-compile-apply chain
   - Clean up saved files on failure to prevent orphans
   - Add TryDeleteFile helper for best-effort cleanup
   - Add AddAndApplyMigrationFailed resource string

2. Context disposal in PrepareForMigration:
   - Wrap context usage in try-catch
   - Dispose context if validation or service building fails
   - Prevents context leaks on validation exceptions

3. Thread safety in MigrationsAssembly:
   - Add lock protection around _additionalAssemblies, _migrations, and _modelSnapshot
   - Protect Migrations property getter, ModelSnapshot property getter, and AddMigrations method
   - Prevents race conditions in multi-threaded scenarios
The snapshot file may have overwritten an existing snapshot during
Save(). Deleting it on failure would leave the project without a
snapshot, breaking future migrations. Only delete migration and
metadata files which are always newly created.
- Remove file deletion on failure (keep files for debugging)
- Inline validation methods into PrepareForMigration
- Remove DisableParallelization from test classes
- Refactor tests to use SharedStoreFixtureBase pattern
- Use NonCapturingLazyInitializer for MigrationsAssembly.Migrations
- Convert to using declarations to reduce nesting
- Make CleanDatabase virtual for provider overrides
- Fix thread safety with lock-based ModelSnapshot caching
@thromel thromel force-pushed the feature/runtime-migrations branch from f3a91b0 to cd324fd Compare January 8, 2026 04:41
@thromel
Copy link
Contributor Author

thromel commented Jan 8, 2026

Question on NonCapturingLazyInitializer for Migrations Property

Per review comment, I switched Migrations from lock-based caching to NonCapturingLazyInitializer. However, after some analysis, there's a potential race condition:

The Issue

NonCapturingLazyInitializer uses Interlocked.CompareExchange which can race with AddMigrations() cache invalidation:

Thread A: Starts computing Migrations (sees _migrations == null)
Thread B: Calls AddMigrations(), adds assembly, sets _migrations = null
Thread A: Finishes computing (without new assembly), stores via CompareExchange
Result: Stale dictionary permanently cached, missing new assembly's migrations

Current Implementation

// Migrations - uses NonCapturingLazyInitializer (per review request)
public virtual IReadOnlyDictionary<string, TypeInfo> Migrations
    => NonCapturingLazyInitializer.EnsureInitialized(
        ref _migrations,
        this,
        static self => { /* compute */ });

// AddMigrations - invalidates cache
public virtual void AddMigrations(Assembly assembly)
{
    lock (_lock)
    {
        _additionalAssemblies.Add(assembly);
        _migrations = null;  // <-- Race: can be overwritten by concurrent EnsureInitialized
        _modelSnapshot = null;
        _modelSnapshotInitialized = false;
    }
}

Options

  1. Keep NonCapturingLazyInitializer - Accept the race (unlikely in practice since AddMigrations is design-time only)
  2. Revert to lock-based caching - Consistent with ModelSnapshot property, eliminates race
  3. Versioned cache - More complex but allows lock-free reads

Practical Risk Assessment

The race is unlikely because:

  • AddMigrations is only called during design-time database update --auto operations
  • It's not expected to be called concurrently with Migrations access
  • The scenario requires very specific timing

Please advise on preferred approach. Happy to adjust either way.

- Move migration name validation before context creation in AddMigration and
  AddAndApplyMigration to ensure proper error messages when name is empty
- Use Single() instead of First() in Migration_preserves_existing_data test
  to avoid FirstWithoutOrderByAndFilterWarning
Replace First() with Single() to avoid FirstWithoutOrderByAndFilterWarning
Close connection before migrator.Migrate("0") call and reopen after,
since the migrator manages its own connection state internally.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow to create a migration and apply it without recompiling

2 participants