Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
451 changes: 451 additions & 0 deletions docs/superpowers/specs/2026-07-15-per-section-dbcontext-design.md

Large diffs are not rendered by default.

29 changes: 25 additions & 4 deletions scripts/pr-surface-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,27 @@ def is_real_migration_file(path: str) -> bool:
path.startswith(MIGRATIONS_PREFIX)
and name.endswith(".cs")
and not name.endswith(".Designer.cs")
and name != "HumansDbContextModelSnapshot.cs"
and not name.endswith("ModelSnapshot.cs")
)


def max_migrations_per_context(migration_files: list[str]) -> int:
"""Max real migrations in any one migration directory.

Since the per-section DbContext split (nobodies-collective/Humans#858) each
context owns its own chain in its own directory (Migrations/ for
HumansDbContext, Migrations/<Section>/ for a peeled section). The
one-migration-per-PR rule applies per chain: a peel PR legitimately carries
one snapshot-only migration on the main chain plus one baseline in the new
section's directory.
"""
per_dir: dict[str, int] = {}
for path in migration_files:
directory = str(Path(normalize(path)).parent)
per_dir[directory] = per_dir.get(directory, 0) + 1
return max(per_dir.values(), default=0)


def parse_name_status(base: str, head: str) -> tuple[list[str], list[str], list[str]]:
raw = run_git(["diff", "--name-status", "--find-renames", "--find-copies", f"{base}...{head}"])
added_files: list[str] = []
Expand Down Expand Up @@ -302,8 +319,12 @@ def build_markdown(
if rows
else "### Diff Size\n\nNo line changes detected."
)
migration_status = "OK" if len(migration_files) <= 1 else "BLOCK"
summary = f"{len(changed_files)} changed file(s) | EF migrations: {len(migration_files)}/1"
max_per_context = max_migrations_per_context(migration_files)
migration_status = "OK" if max_per_context <= 1 else "BLOCK"
summary = (
f"{len(changed_files)} changed file(s) | EF migrations: "
f"{len(migration_files)} file(s), max {max_per_context}/1 per context"
)

compared_line = f"Compared `{short_ref(base_label)}`...`{short_ref(head_label)}`."
if reforge_version:
Expand Down Expand Up @@ -387,7 +408,7 @@ def main() -> int:
"added_files": added_files,
"changed_files": changed_files,
"migration_files": migration_files,
"migration_count": len(migration_files),
"migration_count": max_migrations_per_context(migration_files),
"reforge": {
"version": args.reforge_version,
"base": base_score,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ namespace Humans.Infrastructure.Hosting;
/// <summary>
/// Applies pending EF migrations in <c>StartingAsync</c>, before cache warmup
/// and other hosted services read tables that may not exist yet.
/// <c>HumansDbContext</c> migrates first (its historical chain provisions the
/// whole schema on fresh databases), then each per-section context registered
/// via <c>AddSectionDbContext</c> runs through <see cref="SectionMigrationRunner"/>
/// (nobodies-collective/Humans#858).
/// </summary>
internal sealed class DatabaseMigrationHostedService(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
internal sealed class DatabaseMigrationHostedService(
IServiceScopeFactory scopeFactory,
ILoggerFactory loggerFactory,
IEnumerable<SectionDbContextRegistration> sectionContexts)
: IHostedLifecycleService
{
private readonly ILogger _logger = loggerFactory.CreateLogger("DatabaseMigration");
Expand All @@ -21,6 +28,12 @@ public async Task StartingAsync(CancellationToken cancellationToken)
var dbContext = scope.ServiceProvider.GetRequiredService<HumansDbContext>();
var dbName = dbContext.Database.GetDbConnection().Database;
await MigrateAsync(dbContext, dbName, cancellationToken);

foreach (var section in sectionContexts)
{
var sectionContext = (DbContext)scope.ServiceProvider.GetRequiredService(section.ContextType);
await SectionMigrationRunner.MigrateAsync(sectionContext, section.SentinelTable, _logger, cancellationToken);
}
}

public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,61 @@ public static IServiceCollection AddHumansPersistence(
return services;
}

private static void ConfigureNpgsql(IServiceProvider sp, DbContextOptionsBuilder options)
/// <summary>
/// Registers a per-section DbContext (nobodies-collective/Humans#858): scoped context +
/// singleton factory with the same Npgsql options and interceptors as
/// <see cref="HumansDbContext"/>, a section-specific
/// <c>__EFMigrationsHistory_&lt;Section&gt;</c> table, and the
/// <see cref="SectionDbContextRegistration"/> consumed by
/// <see cref="DatabaseMigrationHostedService"/> to run
/// <see cref="SectionMigrationRunner"/> at startup.
/// </summary>
/// <param name="sentinelTable">See <see cref="SectionDbContextRegistration.SentinelTable"/>.</param>
internal static IServiceCollection AddSectionDbContext<TContext>(
this IServiceCollection services,
string sentinelTable)
where TContext : DbContext
{
// AgentDbContext -> __EFMigrationsHistory_Agent
var historyTable = "__EFMigrationsHistory_" +
typeof(TContext).Name.Replace("DbContext", "", StringComparison.Ordinal);

services.AddDbContext<TContext>((sp, options) =>
{
ConfigureNpgsql(sp, options, historyTable);
options.AddInterceptors(sp.GetRequiredService<QueryMonitoringInterceptor>());
options.AddInterceptors(sp.GetRequiredService<UserInfoSaveChangesInterceptor>());
options.AddInterceptors(sp.GetRequiredService<LegalDocumentSaveChangesInterceptor>());
options.ConfigureWarnings(w => w.Ignore(CoreEventId.FirstWithoutOrderByAndFilterWarning));
}, optionsLifetime: ServiceLifetime.Singleton);

services.AddDbContextFactory<TContext>((sp, options) =>
{
ConfigureNpgsql(sp, options, historyTable);
options.AddInterceptors(sp.GetRequiredService<UserInfoSaveChangesInterceptor>());
options.AddInterceptors(sp.GetRequiredService<LegalDocumentSaveChangesInterceptor>());
options.ConfigureWarnings(w => w.Ignore(CoreEventId.FirstWithoutOrderByAndFilterWarning));
});

services.AddSingleton(new SectionDbContextRegistration(typeof(TContext), sentinelTable));

return services;
}

private static void ConfigureNpgsql(
IServiceProvider sp,
DbContextOptionsBuilder options,
string? migrationsHistoryTable = null)
{
options.UseNpgsql(sp.GetRequiredService<NpgsqlDataSource>(), npgsqlOptions =>
{
npgsqlOptions.UseNodaTime();
npgsqlOptions.MigrationsAssembly("Humans.Infrastructure");
npgsqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
if (migrationsHistoryTable is not null)
{
npgsqlOptions.MigrationsHistoryTable(migrationsHistoryTable);
}
});
}

Expand Down
17 changes: 17 additions & 0 deletions src/Humans.Infrastructure/Hosting/SectionDbContextRegistration.cs
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 src/Humans.Infrastructure/Hosting/SectionMigrationRunner.cs
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);

Copy link
Copy Markdown
Owner Author

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 warmed SystemSettingsDbContext runners together on an old-chain database: startup failed at line 98 with SQLSTATE 23505 (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 GetAppliedMigrationsAsync and 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.

}

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);
Comment thread
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);
}
}
Loading