forked from nobodies-collective/Humans
-
Notifications
You must be signed in to change notification settings - Fork 4
858/00: per-section DbContext split — design doc + baseline helper skeleton #1112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
peterdrier
wants to merge
3
commits into
main
Choose a base branch
from
858/00-design
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
e340a3f
docs(architecture): per-section DbContext split design + baseline-det…
peterdrier 84cd137
docs+ci(858): record the scaffolded-default audit class; per-context …
peterdrier efc187a
fix(858): close sentinel-check connection and use ProductInfo.GetVers…
peterdrier File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
451 changes: 451 additions & 0 deletions
451
docs/superpowers/specs/2026-07-15-per-section-dbcontext-design.md
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
src/Humans.Infrastructure/Hosting/SectionDbContextRegistration.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| namespace Humans.Infrastructure.Hosting; | ||
|
|
||
| /// <summary> | ||
| /// Descriptor for a per-section DbContext registered via | ||
| /// <see cref="InfrastructureServiceCollectionExtensions.AddSectionDbContext{TContext}"/>. | ||
| /// Consumed by <see cref="DatabaseMigrationHostedService"/> to migrate each section | ||
| /// context through <see cref="SectionMigrationRunner"/> after the main | ||
| /// <c>HumansDbContext</c> chain has been applied. | ||
| /// </summary> | ||
| /// <param name="ContextType">The section's DbContext CLR type.</param> | ||
| /// <param name="SentinelTable"> | ||
| /// One stable table owned by the section (unqualified name, <c>public</c> schema). | ||
| /// Its presence on a database whose section history table is empty means the tables | ||
| /// were created by the historical <c>HumansDbContext</c> chain, so the section's | ||
| /// baseline migration must be recorded as applied without executing. | ||
| /// </param> | ||
| internal sealed record SectionDbContextRegistration(Type ContextType, string SentinelTable); |
102 changes: 102 additions & 0 deletions
102
src/Humans.Infrastructure/Hosting/SectionMigrationRunner.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||
| using Microsoft.EntityFrameworkCore.Migrations; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace Humans.Infrastructure.Hosting; | ||
|
|
||
| /// <summary> | ||
| /// Migrates a per-section DbContext with real-up baseline detection | ||
| /// (nobodies-collective/Humans#858). | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// Each section context carries a baseline migration containing the full | ||
| /// <c>CreateTable</c>/index/FK operations for its tables. While the historical | ||
| /// <c>HumansDbContext</c> chain remains intact, that chain still creates every | ||
| /// section's tables, so on all real databases (fresh and existing alike) the | ||
| /// section's tables already exist by the time the section context migrates — | ||
| /// executing the baseline would fail. This runner decides per context: | ||
| /// </para> | ||
| /// <list type="bullet"> | ||
| /// <item>Section history table has rows → plain <c>MigrateAsync</c> (no-op or | ||
| /// pending post-baseline migrations).</item> | ||
| /// <item>History empty and the sentinel table exists → record the baseline as | ||
| /// applied WITHOUT executing it (using EF's own <see cref="IHistoryRepository"/> | ||
| /// script generation — the same mechanism <c>MigrateAsync</c> uses to record | ||
| /// migrations), then <c>MigrateAsync</c> for anything after the baseline.</item> | ||
| /// <item>History empty and the sentinel table absent (genuinely fresh database | ||
| /// that the historical chain does not provision, e.g. a section context running | ||
| /// in isolation, or any fresh database after the future history shrink) → | ||
| /// plain <c>MigrateAsync</c>, which executes the baseline for real.</item> | ||
| /// </list> | ||
| /// <para> | ||
| /// Idempotent by construction: once the baseline history row exists the first | ||
| /// branch is taken forever. Only the baseline (the earliest migration of the | ||
| /// section context) is ever fake-applied; later section migrations always run. | ||
| /// </para> | ||
| /// </remarks> | ||
| internal static class SectionMigrationRunner | ||
| { | ||
| public static async Task MigrateAsync(DbContext db, string sentinelTable, ILogger logger, CancellationToken ct) | ||
| { | ||
| var contextName = db.GetType().Name; | ||
| var applied = (await db.Database.GetAppliedMigrationsAsync(ct)).ToList(); | ||
|
|
||
| if (applied.Count == 0 && await SentinelTableExistsAsync(db, sentinelTable, ct)) | ||
| { | ||
| var baselineId = db.Database.GetMigrations().First(); | ||
| logger.LogWarning( | ||
| "{Context}: tables exist but history is empty - recording baseline {Baseline} as applied without executing", | ||
| contextName, baselineId); | ||
| await RecordBaselineAsAppliedAsync(db, baselineId, ct); | ||
| } | ||
|
|
||
| var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList(); | ||
| if (pending.Count > 0) | ||
| { | ||
| foreach (var migration in pending) | ||
| { | ||
| logger.LogWarning("{Context}: applying pending migration: {Migration}", contextName, migration); | ||
| } | ||
|
|
||
| await db.Database.MigrateAsync(ct); | ||
| } | ||
| else | ||
| { | ||
| logger.LogInformation("{Context}: schema is up to date", contextName); | ||
| } | ||
| } | ||
|
|
||
| private static async Task<bool> SentinelTableExistsAsync(DbContext db, string sentinelTable, CancellationToken ct) | ||
| { | ||
| await db.Database.OpenConnectionAsync(ct); | ||
|
peterdrier marked this conversation as resolved.
|
||
| try | ||
| { | ||
| var connection = db.Database.GetDbConnection(); | ||
| var command = connection.CreateCommand(); | ||
| await using (command.ConfigureAwait(false)) | ||
| { | ||
| command.CommandText = "SELECT to_regclass(@table) IS NOT NULL"; | ||
| var parameter = command.CreateParameter(); | ||
| parameter.ParameterName = "@table"; | ||
| parameter.Value = "public." + sentinelTable; | ||
| command.Parameters.Add(parameter); | ||
| var result = await command.ExecuteScalarAsync(ct); | ||
| return result is true; | ||
| } | ||
| } | ||
| finally | ||
| { | ||
| await db.Database.CloseConnectionAsync(); | ||
| } | ||
| } | ||
|
|
||
| private static async Task RecordBaselineAsAppliedAsync(DbContext db, string baselineId, CancellationToken ct) | ||
| { | ||
| var historyRepository = db.GetService<IHistoryRepository>(); | ||
| await db.Database.ExecuteSqlRawAsync(historyRepository.GetCreateIfNotExistsScript(), ct); | ||
| await db.Database.ExecuteSqlRawAsync( | ||
| historyRepository.GetInsertScript(new HistoryRow(baselineId, ProductInfo.GetVersion())), ct); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Serialize the check-and-record path across application instances
On the first deployment of a peel, every new replica can observe an empty section history table and an existing sentinel. The read at line 44 and the raw create/insert here are a check-then-act sequence outside EF's migration lock, so concurrent starters can both enter
RecordBaselineAsAppliedAsync. I reproduced this against Postgres by releasing 50 warmedSystemSettingsDbContextrunners together on an old-chain database: startup failed at line 98 with SQLSTATE23505(duplicate key value violates unique constraint "pg_type_typname_nsp_index") while the runners raced to create the history table; a race after creation can likewise duplicate the baseline PK insert.MigrateAsync's later lock does not protect these raw commands.Please acquire a database-wide/provider migration lock before
GetAppliedMigrationsAsyncand hold it through the fake-apply decision and insert (or otherwise make the whole path concurrency-safe), and add a concurrent-start integration test. Without that, a normal multi-replica production rollout can fail health/startup on the first deploy of every newly peeled context.