fix: migration ordering bug that could block fresh-DB deploys

V004_normalize_snapshots_schema.sql had an unguarded FK to a table V2
creates. Under DbUp's default ordinal filename sort, the zero-padded
"V003_"/"V004_" migrations sorted before "V1__", so on a brand-new
database V004 would hard-fail on that FK and abort every migration
after it - V1 through V8 would never run. Confirmed via production
that neither V003 nor V004 had ever actually applied.

Fix: renamed them to V9__/V10__ and added MigrationScriptNameComparer,
which sorts DbUp scripts by numeric V{n} value instead of raw string
order, so double-digit versions can never again sort ahead of earlier
single-digit ones. Added regression tests for both the fixed case and
the original bug shape.

Also updates docs/db/quantengine.dbml (renamed migrations, and closes
out the engine_history/quantengine table-name-collision question -
both schemas are live, backing different code paths, not duplicates)
and CLAUDE.md (migration ordering fix, KIS/OpenDART/KRX credential
env-var-name reference, including a CI secret/env-var name mismatch
found for OpenDART that still needs a decision).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 11:36:00 +09:00
parent 70824c2afb
commit 279d1760ef
7 changed files with 159 additions and 27 deletions
@@ -28,6 +28,7 @@ namespace QuantEngine.Infrastructure.Data
var upgrader = DeployChanges.To
.PostgresqlDatabase(_connectionString)
.WithScriptsEmbeddedInAssembly(typeof(DbMigrator).Assembly, s => s.StartsWith("QuantEngine.Infrastructure.Migrations"))
.WithScriptNameComparer(new MigrationScriptNameComparer())
.LogToConsole()
.Build();
@@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace QuantEngine.Infrastructure.Data
{
/// <summary>
/// Orders DbUp migration scripts by their numeric V{n} version instead of plain ordinal
/// string order. Without this, "V10__Name.sql" sorts before "V2__Name.sql" (and, as
/// happened on 2026-07-24, zero-padded "V003_Name.sql" sorts before unpadded
/// "V1__Name.sql") because ordinal comparison looks at characters, not numeric value.
/// That mismatch let a migration with an unmet table dependency run first and silently
/// no-op, and another one run first and hard-fail, blocking every migration after it on a
/// fresh database. This comparer makes the "V{n}" scheme collision-proof regardless of
/// digit count or padding, so it can never happen again.
/// </summary>
public class MigrationScriptNameComparer : IComparer<string>
{
private static readonly Regex VersionPattern = new(@"V(\d+)", RegexOptions.Compiled);
public int Compare(string? x, string? y)
{
if (x == null || y == null)
{
return string.CompareOrdinal(x, y);
}
var matchX = VersionPattern.Match(x);
var matchY = VersionPattern.Match(y);
if (matchX.Success && matchY.Success)
{
var versionX = long.Parse(matchX.Groups[1].Value);
var versionY = long.Parse(matchY.Groups[1].Value);
if (versionX != versionY)
{
return versionX.CompareTo(versionY);
}
}
return string.CompareOrdinal(x, y);
}
}
}