Skip to main content

managing-shared-database-schema

Governs a SQL database that two hosts read and write at the same time during a side-by-side migration. Use when a legacy .NET Framework app and a new .NET app share one database, point at the same connection string, or must both stay live while the schema changes. Covers which toolchain owns schema changes, expand-then-contract (additive-only) evolution, dual-write and backfill ordering, deployment ordering, and rollback rules. Triggers for "shared database", "who runs migrations", "dual write", "zero downtime schema change", or "add a column safely". Applies to EF6, EF Core, Dapper, ADO.NET, DACPAC/SqlPackage, DbUp and Flyway, and explains how EF6's __MigrationHistory and EF Core's __EFMigrationsHistory coexist without merging. Also use before deleting a Migrations folder, before enabling Database.Migrate() at startup, or when asked whether renaming or dropping a column is safe while the old app is live.

Informations de source

Dépôt
microsoft/upgrade-agent-plugins
Dernière activité de la source
10 septembre 2026 à 04:23
Langue détectée de SKILL.md
anglais
Étoiles
41
Forks
9

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Explorateur de fichiers
2 fichiers

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
name
managing-shared-database-schema
description
Governs a SQL database that two hosts read and write at the same time during a side-by-side migration. Use when a legacy .NET Framework app and a new .NET app share one database, point at the same connection string, or must both stay live while the schema changes. Covers which toolchain owns schema changes, expand-then-contract (additive-only) evolution, dual-write and backfill ordering, deployment ordering, and rollback rules. Triggers for "shared database", "who runs migrations", "dual write", "zero downtime schema change", or "add a column safely". Applies to EF6, EF Core, Dapper, ADO.NET, DACPAC/SqlPackage, DbUp and Flyway, and explains how EF6's __MigrationHistory and EF Core's __EFMigrationsHistory coexist without merging. Also use before deleting a Migrations folder, before enabling Database.Migrate() at startup, or when asked whether renaming or dropping a column is safe while the old app is live.
metadata
{"traits":".NET|CSharp|VisualBasic|DotNetCore","discovery":"lazy"}
# Managing a Shared Database During Side-by-Side Operation ## Overview In a side-by-side migration, the legacy .NET Framework host and the new .NET host both run against **one SQL database** for weeks or months. Every schema change in that window must be tolerated by code that is still running and by code that is being deployed — simultaneously. This skill is **ORM-agnostic**. The rules apply equally to EF6, EF Core, Dapper, raw ADO.NET, and SQL-first tooling. Where a rule needs a concrete binding, the EF6 and EF Core forms are given side by side. > **Read `ref/worked-example.md` before making any schema change.** It carries the full > column-rename walkthrough with the exact ordering. The phase skeleton below is a summary, > not a substitute. ## Scope **In scope:** one database, two or more live application hosts, a bounded migration window. **Out of scope:** ETL and data warehousing, database-per-service decomposition, read replicas and sharding, cross-database distributed transactions. **Related skills:** - `migrating-ef6-code-first-to-ef-core` — the EF6→EF Core code migration. Its teardown steps are for **full cutover only** and must not run while both hosts are live. - `migrating-ef-dbcontext` — DbContext registration and DI. Its startup-migration sample is gated on the ownership decision made here. - `migrating-aspnet-framework-to-core` — the side-by-side task structure that creates this window. ## Window admission checklist Do not open the shared-database window until all five hold: - [ ] **Schema-writer inventory exists** (Step 1 below) and is written into `task.md` - [ ] **Actual schema matches the model** — no undeployed pending migrations on either side - [ ] **Startup migration is disabled on both hosts** — no `Database.Migrate()` in `Program.cs`, no `MigrateDatabaseToLatestVersion` initializer, no `EnsureCreated()` - [ ] **Backup / point-in-time restore verified** — tested, not merely configured - [ ] **DDL permissions restricted** to the single runner identity; application logins hold DML only (`db_datareader` + `db_datawriter`, not `db_ddladmin`/`db_owner`) If any check fails, fix it before proceeding. Opening the window with two hosts able to issue DDL is the failure mode this skill exists to prevent. ## Step 1: Schema-writer inventory Produce a **written table in `task.md`**. Do not proceed on recollection or assumption. Search the whole solution — both hosts, and any jobs, tools or deployment scripts: | What to search for | Indicates | |---|---| | `DbMigrator`, `migrate.exe`, `Update-Database` | EF6 migration runner | | `Database.Migrate()`, `dotnet ef database update` | EF Core migration runner | | `EnsureCreated()`, `CreateDatabaseIfNotExists`, `DropCreateDatabase*` | implicit schema creation | | `SqlPackage`, `.sqlproj`, `.dacpac` | SQL-first deployment | | `DbUp`, `Flyway`, `Liquibase`, `RoundhousE` | third-party migrator | | `CREATE TABLE`, `ALTER TABLE` in `.sql` files or embedded strings | ad-hoc DDL | | connection-string names shared across `*.config` and `appsettings*.json` | the shared database itself | Record one row per finding, each citing `path:line`: | Writer | Toolchain | Where it runs | Evidence | |---|---|---|---| | `MyApp.MigrationRunner` | EF6 `DbMigrator` | scheduled job | `src/Jobs/Migrate.cs:42` | **Do not select an ownership case until the table has at least one evidenced row**, or an explicit "none found — asked the user and they confirmed X". ## Step 2: Decide who owns schema changes This is a **procedure**, not a lookup. Follow it in order. 1. Build the inventory (Step 1). 2. **If more than one DDL-capable writer type appears — stop and ask the user who owns the pen.** Do not infer it. Two writers with two ledgers is the root cause of nearly every failure in this window. 3. If exactly one appears — that is **Case A**. Still enforce a single deployable runner. 4. Choose **Case C** only when the user confirms SQL-first ownership, or when the window is long enough that neither ORM should hold the pen. Record the decision and its rationale in `task.md`. Every later step depends on it. ### Case A — one toolchain, one runner *(most common)* Both web hosts are inert with respect to schema. One deployable migrator applies changes. - No startup migration in either host. - One runner, one place, one schedule. - **Concurrency is the residual risk.** EF6's `DbMigrator` takes **no** distributed lock. EF Core 9+ *does* take one (`__EFMigrationsLock`) — but the two **do not coordinate with each other**. Two concurrent runners can interleave DDL. - **They can be made to coordinate.** On SQL Server, EF Core's lock is an `sp_getapplock` taken on the resource name `__EFMigrationsLock`. A non-EF-Core runner that takes an exclusive `sp_getapplock` on that *same* resource name therefore serializes against `dotnet ef database update` as well as against itself. This is an EF Core implementation detail, not a documented contract — confirm it for your EF Core version before relying on it, and keep "one runner" as the primary control rather than the lock. - Serialize explicitly. Either run migrations from a single scheduled job that cannot overlap itself, or wrap the runner in an application lock: ```sql DECLARE @lock int; EXEC @lock = sp_getapplock @Resource = 'schema-migration', @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = 60000; IF @lock < 0 THROW 50000, 'Could not acquire the schema-migration lock; another runner holds it.', 1; -- run the migrations now, then release. The migration may open its own -- connection; this one only has to stay open for the whole run to hold the lock. EXEC sp_releaseapplock @Resource = 'schema-migration', @LockOwner = 'Session'; ``` Two details decide whether this actually protects anything: - **Check the return value.** `sp_getapplock` does *not* raise an error on failure — it returns a negative value (`-1` timeout, `-2` cancelled, `-3` deadlock victim, `-999` argument error). An `EXEC` whose result nobody inspects therefore **fails open**: the timeout elapses, the batch continues, and the migration runs with no lock at all — precisely the case this is meant to prevent. - **`@LockOwner = 'Session'` binds the lock to the connection that took it**, and that connection must stay open for the whole run. The migration itself may execute on a different connection — the lock provides mutual exclusion between *runners*, not between connections — which is what makes this usable with EF6's `DbMigrator`, since it opens its own connection. What breaks the guarantee is closing the lock-holding connection early, or a runner that deploys without taking the lock at all. Use `@LockOwner = 'Transaction'` only if the migration genuinely runs inside that transaction. ### Case B — two toolchains, one holds the pen **Default: EF6 keeps the pen** for the duration of the window. Two reasons, both concrete: - **Convention drift.** EF6 names constraints `FK_dbo.Posts_dbo.Blogs_BlogId`; EF Core names them `FK_Posts_Blogs_BlogId`. EF Core scaffolds a migration by diffing its model against **its own snapshot, never against the live database**, so it cannot see those legacy names. An unbaselined first migration therefore emits `CREATE` for the entire model — every object it "creates" already exists — and once baselined, subsequent migrations emit `ALTER`/`DROP` against EF Core-convention names that are not what the database actually calls them. - **Variable isolation.** Moving schema deployment from `DbMigrator`/`migrate.exe` to `dotnet ef` *during* the window conflates an infrastructure migration with an application migration, on the host still serving production traffic. **When to override the default.** Hand the pen to EF Core instead only when all three hold — and record the reason in `task.md`: 1. The Framework host is **feature-frozen**: kept alive to serve traffic, not developed against. Authoring every schema change in a codebase no one is working in is real friction, and this is the case the default gets wrong. 2. EF Core's **model** has been reverse-engineered from the live database with `Scaffold-DbContext`, so it already carries the legacy constraint names (see the bootstrap rule below). This is a *model* bootstrap and nothing more: `Scaffold-DbContext` produces entity classes and a `DbContext`. It creates no migration, no `ModelSnapshot.cs` and no `__EFMigrationsHistory` row, so it does **not** baseline the ledger. 3. The switch happens **once, at a planned checkpoint** — never per change — and the Step 3 handover procedure (scaffold the baseline, review it, register it with the history-table script) completes **within that same checkpoint**. EF Core must not own the pen for even one change while its ledger is still empty: its first `migrations add` would diff against an empty snapshot and emit `CreateTable` for the entire model. If any of the three fails, EF6 keeps the pen. Either way the handover is a one-time event: alternating writers is prohibited (Step 3). Rules: - The non-owner **never scaffolds a migration**. It models the schema read-only. - If EF Core must eventually take the pen, bootstrap it with `Scaffold-DbContext` against the live database. That emits explicit `HasConstraintName(...)` calls pinning EF Core to the legacy names, neutralising the convention diff. It does **not** register a ledger baseline — that is Step 3's handover procedure, and both are required before EF Core owns the pen. - **Pin delete behaviour explicitly — "we emit no DDL" does not make it moot.** `DeleteBehavior` governs two separate things: the FK clause EF Core would write *in a migration*, and what EF Core does *at runtime* to dependents it has loaded. The non-owner emits no migration, so the first is irrelevant — which is precisely why it gets skipped, and why the second then bites. A required (non-nullable) FK defaults to `DeleteBehavior.Cascade`, so deleting a principal makes EF Core issue `DELETE` for each tracked child. Against a live FK declared `NO ACTION` those child deletes are perfectly legal and succeed, so the rows vanish — while the EF6 host doing the same delete gets an FK violation. One database, two behaviours, and no error anywhere to reveal it. Mirror the live constraint: `WillCascadeOnDelete(false)` or `NO ACTION` means `.OnDelete(DeleteBehavior.Restrict)`. Read the real FK; never accept the convention default. ### Case C — neither ORM owns; SQL-first Both ORMs are frozen as read-projections of a schema owned by DACPAC/SqlPackage, DbUp or similar. That system carries its own authoritative ledger. Neither `Add-Migration` nor `dotnet ef migrations add` is run at all. ## Step 3: Migration ledgers coexist — they never merge EF6 and EF Core write **different tables**: | | EF6 | EF Core | |---|---|---| | Table | `dbo.__MigrationHistory` | `dbo.__EFMigrationsHistory` | | Model snapshot | gzipped EDMX in the `Model` column | `<DbContext>ModelSnapshot.cs` in source | They never collide — which is **worse** than colliding. Each runner scaffolds its next migration by diffing against *its own* snapshot, so EF6's `Add-Migration` happily re-adds a column EF Core already created, and vice versa. Rules: - **Never** point EF Core at EF6's history table. - **Never** copy rows between the two tables. The formats are not interchangeable. - Preserve EF6's ledger **read-only** — the Framework host still needs it. - **Handover** (at the moment EF Core takes ownership of schema deployment — by default at cutover, and during the window only under the Step 2 override): scaffold an EF Core baseline migration. It **will contain the full `CreateTable` set for the whole model** — a first migration diffs against an empty snapshot, so an empty `Up()` is not something you can wait for, and an instruction to "verify it contains no operations" can never be satisfied. Review it to confirm it describes the schema that *already exists*, then register it **without executing it**: generate `dotnet ef migrations script --idempotent` and apply only its history-table parts — the conditional `CREATE TABLE __EFMigrationsHistory` block (an EF6-only database has no such table, so the insert alone fails) and the `INSERT INTO __EFMigrationsHistory` row. Do **not** use `dotnet ef database update` — that command *executes* the migration; EF Core has no `--fake`. - Where several `DbContext` types share one database, each has its own history rows. Decide behaviour per context, not per database. - Changing owner requires a **fresh baseline checkpoint**. Alternating writers is prohibited. > **Precondition:** the EF6 host must call `Database.SetInitializer<MyContext>(null)`. > An app that keeps `MigrateDatabaseToLatestVersion` runs `DbMigrator.Update()` on first > context use, which **applies any pending EF6 migration automatically at startup** — a second > uncoordinated schema writer that deploys on process start rather than on your schedule. > Nulling the initializer also bypasses the model-compatibility check > (`Database.CompatibleWithModel`), which the initializer pipeline invokes. Be clear about what > that check does *not* do: it compares the current model against the model hash stored in > `__MigrationHistory`, so it reacts to **your code** changing, not to the physical schema > advancing underneath it. Additive DDL applied out of band does not trip it. > > With the initializer nulled, EF6 **silently tolerates** added columns and tables, because it > emits explicit column lists rather than `SELECT *`. It fails only when a column it *knows > about* is dropped or renamed, or when a `NOT NULL` column without a default is added and EF6 > inserts a row. ## Step 4: Expand-then-contract — additive only **Inside the window, schema changes are additive only.** Contraction happens after the legacy host is retired. **Prohibited while both hosts are live:** | Change | Why it breaks | |---|---| | Rename a column or table | legacy host's explicit column list stops resolving | | Drop a column or table | same, immediately | | Narrow a type (`nvarchar(200)`→`(50)`, `bigint`→`int`) | legacy writes overflow | | Change a primary key or clustered index | rewrites the table; blocks both hosts | | **Add a UNIQUE constraint or unique index** *on a column a host already writes* | the legacy host keeps writing duplicates | | **Create a non-clustered index without `ONLINE = ON`** | takes a lock for the build's duration | | Add `NOT NULL` without a default | legacy `INSERT` omits the column and fails | The unique-index row applies to columns that are **already being written**. A *new* column is the different case: once the Step 5 dual-write is deployed to every writer, they all populate it, so a **filtered** unique index becomes legal at the Enforce phase — see Step 5 phase 6 and `ref/worked-example.md` Phase 6, which shows why the same index is illegal at expand time. **Adding a `NOT NULL` column with a default is metadata-only — and therefore safe — only when all four hold:** 1. **SQL Server 2012 or later, Enterprise edition** (Evaluation, and Developer on 2022 and earlier, are Enterprise-equivalent; **SQL Server 2025 Standard Developer is not** — that release splits Developer into Enterprise Developer and Standard Developer, and the latter carries Standard's limits), or Azure SQL. On **Standard this is a full table rewrite under a `Sch-M` lock** regardless of everything below — the most common way this step goes wrong is assuming otherwise. Validating on a Standard Developer box and concluding production will behave the same way is the version of that mistake this note exists to prevent. 2. The default is a **runtime constant** — an expression producing the same value for every row, such as `0`, `''`, or `GETDATE()`. **Not** `NEWID()` or `NEWSEQUENTIALID()`, and not a scalar UDF; those force a full rewrite. 3. The column's type is **not** `varchar(max)`, `nvarchar(max)`, `varbinary(max)`, `xml`, `text`, `ntext`, `image`, `hierarchyid`, `geometry`, `geography`, or a CLR UDT. These are excluded from the online path outright, independently of the other conditions. 4. Adding it does not push the table's **maximum possible row size** past 8,060 bytes. If any fail, treat it as a rewrite: add the column nullable, backfill in batches, then enforce. **Safe — but only under these conditions:** | Change | Condition | |---|---| | Add a nullable column | unconditional | | Add a new table | unconditional | | Add a default constraint | unconditional — it does not touch existing rows | | Add a non-clustered index `ONLINE = ON` | **Enterprise or Azure SQL only.** Still takes a short lock at the start and a `Sch-M` lock to finish, and needs log and `tempdb` headroom. On Standard there is no online path, so defer the index to contraction. | | Widen a type | only when the widening is genuinely metadata-only. `varchar(50)`→`varchar(100)` is; **`int`→`bigint` and `varchar`→`nvarchar` rewrite the whole table.** `int`→`bigint` is also an *application* break: as soon as the new host writes a value beyond `Int32`, the legacy host's mapping overflows reading it back. | ## Step 5: Phase skeleton Every non-trivial change follows this order. **Ordering is not stylistic — inverting steps 2 and 3 loses data.** 1. **Expand** — add the new nullable column/table. Deploy alone. 2. **Dual-write** — deploy to **every** writer, so new writes populate old *and* new. 3. **Backfill** — batched, restartable, race-safe; only rows the dual-write did not cover. 4. **Verify** — old and new agree continuously; no unmigrated writer remains. 5. **Read switch** — read new, fall back to old. Deploy. Soak. 6. **Enforce** — add the final constraint (a filtered unique index, or `NOT NULL` where the value is genuinely mandatory). Keep dual-writing: enforcement does not require the old write to stop, and stopping it early strands every old reader you have not yet retired. 7. **Contract** — stop the old write *and* drop the old column, **only** after every old consumer is retired. These belong together: from the moment old writes stop, any surviving reader of the old column is silently reading stale data, and a rollback to a pre-dual-write revision can no longer be trusted. Backfilling before the dual-write is deployed loses every row written between the two — this is the single most common way to corrupt data in this window. See `ref/worked-example.md`. ## Step 6: Deployment ordering Schema first, always. Every schema change must be independently deployable and must not require a simultaneous code deployment. Per change: | PR | Contains | Done when | |---|---|---| | PR1 | DDL only | new column exists; both hosts still healthy | | PR2 | dual-write in every writer | new column populated on every new write | | PR3 | backfill job | `COUNT(*) WHERE new IS NULL AND old IS NOT NULL` = 0 | | PR4 | read switch | new path serving reads; error rate flat | | PR5 | constraint DDL only | constraint enforced; dual-write still running | | PR6 | contract (post-retirement): stop the old write, drop the read fallback, **unmap the property from every model** | deployed and soaked, **before** any `DROP` | | PR7 | contract DDL | old column dropped | PR6 and PR7 are separate deployments on purpose: dropping the column in the same unit that stops writing it leaves no window in which to discover a consumer the retirement inventory missed. Unmapping the property belongs in PR6 for a harder reason — EF emits every mapped scalar in its `SELECT` list, so a model still mapping a dropped column fails *every* read of that entity, not only the paths that use it. Never combine DDL and application logic in one deployable unit. ## Step 7: Rollback contract **Invariant: every schema state must be tolerated by all four of** {old Framework code, new Framework code, old Core code, new Core code}.
Voir sur GitHub
Ce SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section. Voir sur GitHub