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:
@@ -286,9 +286,16 @@ Pipeline) and Collection Run status/success-criteria definitions:
|
||||
|
||||
## OpenDART Fallback API (2026-07-30)
|
||||
|
||||
**Credential**: `DART_API_KEY` 환경변수 (KIS 키와 동일한 패턴 — 코드/문서/워크플로우 파일에
|
||||
절대 하드코딩하지 않는다. 실제 키 값은 로컬 환경변수 또는 Gitea Secrets에만 존재해야 한다).
|
||||
로딩 위치: `tools/ingest_fundamental_raw.py:55` (`os.environ.get("DART_API_KEY")`).
|
||||
**Credential**: Gitea Secrets에는 `OPENDART_OPENAPI_KEY`라는 이름으로 이미 등록되어 있음
|
||||
(사용자 확인, 2026-07-30). **⚠️ 이름 불일치 주의**: 실제 코드(`tools/ingest_fundamental_raw.py:55`,
|
||||
`os.environ.get("DART_API_KEY")`)는 `DART_API_KEY`라는 *다른* 환경변수명을 읽는다. `.gitea/workflows/`
|
||||
전체를 확인했지만 `OPENDART_OPENAPI_KEY` → `DART_API_KEY` 매핑이 어떤 워크플로우에도 없다 —
|
||||
즉 지금 이 스크립트를 CI에서 돌리면 Gitea Secrets에 값이 있어도 코드가 못 읽는다. 다음 중
|
||||
하나로 해소해야 함(사람 판단 필요, 여기서는 실행하지 않음):
|
||||
(a) 워크플로우 step에 `env: DART_API_KEY: ${{ secrets.OPENDART_OPENAPI_KEY }}` 매핑 추가, 또는
|
||||
(b) 코드가 읽는 환경변수명을 `OPENDART_OPENAPI_KEY`로 통일.
|
||||
로컬 개발 시에는 이름 불일치와 무관하게 `DART_API_KEY`로 설정해야 현재 코드가 그대로 동작한다.
|
||||
실제 키 값은 코드/문서/워크플로우 파일에 절대 하드코딩하지 않는다(KIS와 동일 원칙).
|
||||
|
||||
**⚠️ CRITICAL: 일일 호출 한도 40,000건** — OpenDART는 계정당 하루 40,000건으로 제한된다.
|
||||
이 한도를 넘으면 그날 나머지 호출이 전부 실패한다. DART를 호출하는 모든 코드(현재는
|
||||
@@ -302,6 +309,23 @@ Pipeline) and Collection Run status/success-criteria definitions:
|
||||
- 일일 호출 수를 실제로 추적하는 카운터가 아직 없다 — 실 데이터 호출을 구현할 때 호출
|
||||
카운트를 파일 또는 DB에 기록하고 한도 근접 시 중단하는 가드를 함께 추가한다.
|
||||
|
||||
## KRX Open API (2026-07-30, 미구현)
|
||||
|
||||
**Credential**: Gitea Secrets에 `KRX_OPENAPI_KEY`라는 이름으로 이미 등록되어 있음
|
||||
(사용자 확인, 2026-07-30). 코드/문서/워크플로우 파일에 실제 값을 절대 하드코딩하지 않는다
|
||||
(KIS와 동일 원칙) — 로컬 개발 시 환경변수로도 `KRX_OPENAPI_KEY`를 그대로 쓸 것(아직 이를
|
||||
읽는 코드가 없으므로 새 이름 지어낼 필요 없음).
|
||||
|
||||
**⚠️ 2026-07-30 확인**: 저장소 전체에 KRX Open API를 실제로 호출하는 클라이언트 코드가
|
||||
**아직 존재하지 않는다** (`KRX`라는 단어 자체는 시장/거래소 명칭으로 여러 spec 파일에
|
||||
등장하지만, API 키를 쓰는 실제 호출부는 없음 — grep으로 확인). `.gitea/workflows/`에도
|
||||
`KRX_OPENAPI_KEY`를 참조하는 곳이 없다. 실제 구현 시:
|
||||
- KIS/OpenDART와 마찬가지로 일일/분당 호출 한도를 KRX 공식 문서에서 확인하고 호출 예산을
|
||||
먼저 설계할 것 (한도를 여기 문서에 기록하지 않은 이유: 사용자로부터 아직 전달받지 않음 —
|
||||
추측해서 적지 않는다).
|
||||
- 신규 클라이언트 추가 시 `AssertReadOnly`류 거버넌스 가드가 필요한지(주문 API 존재 여부)
|
||||
KRX 공식 문서로 먼저 확인.
|
||||
|
||||
## Local Development & Testing
|
||||
|
||||
### Mandatory Pre-Deployment Checklist
|
||||
@@ -344,7 +368,7 @@ troubleshooting table: [docs/DEV_WORKFLOWS.md](docs/DEV_WORKFLOWS.md)
|
||||
|
||||
- **Legacy Code**: No `QuantEngine.Web.Client` remains in the repo — fully removed (verified 2026-07-30), not just excluded from `.sln`.
|
||||
- **DBML Sync (2026-07-12)**: All DbUp migrations (`V*.sql`) must sync with `docs/db/quantengine.dbml` in same commit. Future schema reads use DBML.
|
||||
- **Migration naming (2026-07-30)**: Existing migrations mix two conventions — `V1__Name.sql` (double underscore, unpadded) and `V003_name.sql` (single underscore, zero-padded). DbUp sorts scripts alphabetically, so `V003_...` actually runs *before* `V1__...`. **Do not rename existing migration files** — DbUp journals applied scripts by filename/checksum, so a rename makes an already-applied migration look new and re-runs it. New migrations must use `V{next_number}__Name.sql` (double underscore, matching V1–V8, the majority convention) so filename order matches intended execution order. **Note**: verified against production (`quantengine.schemaversions`, 2026-07-30) that `V003`/`V004` have never actually run — only V1–V8 are journaled. Renumbering V003/V004 to `V9__`/`V10__` is therefore safe from a checksum standpoint, but changes their execution order relative to V1–V8 (they currently run *first*); that's a real design decision (open, not made here), not a blind rename.
|
||||
- **Migration naming/ordering (2026-07-30, fixed)**: `V003_add_audit_trail_tables.sql` and `V004_normalize_snapshots_schema.sql` used to sort *before* `V1__Initial_Schema.sql` under DbUp's default ordinal filename sort (zero-padded `V003` < unpadded `V1`). Verified against production that neither had ever actually applied (absent from `quantengine.schemaversions`) — and investigation showed *why*: `V004` has an unguarded FK to a table `V2` creates, so on a fresh database it would hard-fail and abort every migration after it (V1–V8 would never run at all); `V003`'s trigger-creation guards would have silently no-op'd forever for the same reason. Fixed by renaming them to `V9__Add_Audit_Trail_Tables.sql` / `V10__Normalize_Snapshots_Schema.sql` (safe — never journaled anywhere) **and** adding `MigrationScriptNameComparer` (`QuantEngine.Infrastructure/Data/MigrationScriptNameComparer.cs`, wired into `DbMigrator.cs` via `.WithScriptNameComparer(...)`), which sorts scripts by numeric `V{n}` value instead of raw string order — so `V10` correctly sorts after `V9`/`V2`/etc. regardless of digit count. This makes the whole scheme collision-proof going forward: new migrations can just use the next integer, `V{n}__Name.sql`, with no padding needed. **Still do not rename V1–V8** — those are already journaled in production, and DbUp identifies applied scripts by filename.
|
||||
- **Diagrams**: Mermaid diagrams in `docs/diagrams/` for state machines, flows, sequences (e.g., collection-pipeline.md).
|
||||
- **WBS Evidence (2026-07-12)**: Task completion = `npm run verify:task -- <TASK_ID>` PASS (BE=DB queries/logs/JSON, FE=Playwright+screenshot). Full check: `npm run verify:wbs`.
|
||||
|
||||
|
||||
+36
-23
@@ -1,13 +1,16 @@
|
||||
// =============================================================================
|
||||
// QuantEngine Database Schema (DBML)
|
||||
// DbUp 마이그레이션(V1~V8, V003, V004)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신
|
||||
// DbUp 마이그레이션(V1~V10)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신
|
||||
// (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화)
|
||||
//
|
||||
// 마이그레이션 파일명 규칙 2종 혼재 (2026-07-30 발견, 미해결):
|
||||
// V1__Name.sql .. V8__Name.sql (더블언더스코어, zero-pad 없음)
|
||||
// V003_name.sql, V004_name.sql (싱글언더스코어, zero-pad)
|
||||
// DbUp는 파일명 알파벳순으로 실행하므로 "V003" < "V1" 순서로 적용됨 — 신규 마이그레이션은
|
||||
// 반드시 하나의 규칙(권장: V{n}__Name.sql)만 사용할 것.
|
||||
// 2026-07-30 해결됨: 예전에 V003_name.sql/V004_name.sql(싱글언더스코어, zero-pad)이
|
||||
// V1__Name.sql(더블언더스코어, zero-pad 없음) 방식과 섞여 있어, DbUp의 기본 알파벳순
|
||||
// 정렬에서 "V003" < "V1"로 먼저 실행되는 문제가 있었다 (V004는 V2가 만드는 테이블에 대한
|
||||
// 하드 FK 제약이 있어 빈 DB에 처음부터 배포하면 V004에서 하드 실패 → V1~V8 전체가 실행
|
||||
// 안 되는 재현성 버그였음). 조치: V003→V9, V004→V10으로 리네임 + DbMigrator.cs에
|
||||
// MigrationScriptNameComparer(숫자 기반 비교자)를 추가해 "V{n}"이 몇 자리 숫자든 항상
|
||||
// 숫자 크기순으로 정렬되도록 함. 신규 마이그레이션은 V{n}__Name.sql 규칙만 사용할 것
|
||||
// (이 비교자 덕분에 V11, V12... 로 계속 늘어나도 더 이상 이 문제가 재발하지 않는다).
|
||||
//
|
||||
// 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성
|
||||
// (DbUp 마이그레이션으로 관리하지 않음, 여기서도 제외)
|
||||
@@ -504,14 +507,15 @@ Table engine_history.outcome_evaluation {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// V003: Audit Trail Tables (quantengine schema, 2026-07-24)
|
||||
// V9: Audit Trail Tables (quantengine schema, 파일: V9__Add_Audit_Trail_Tables.sql,
|
||||
// 원래 이름 V003_add_audit_trail_tables.sql, 2026-07-30에 V9로 리네임 — 위 헤더 참고)
|
||||
//
|
||||
// 2026-07-30 확정 (실제 프로덕션 DB 조회로 검증): 이 마이그레이션은 CREATE TABLE 안에
|
||||
// MySQL 전용 인라인 "INDEX name (cols)" 구문을 사용해 PostgreSQL에서 문법 오류로 실패했다.
|
||||
// quantengine.schemaversions(DbUp 저널)에 V003이 아예 기록되어 있지 않고, 아래 3개 테이블도
|
||||
// 프로덕션에 실제로 존재하지 않음을 직접 확인했다. V003_add_audit_trail_tables.sql의 인라인
|
||||
// INDEX 구문은 이미 별도 CREATE INDEX 문으로 수정됐으므로, 다음 배포 시 DbUp가 이 마이그레이션을
|
||||
// 최초로 실행해 아래 3개 테이블을 생성할 것이다.
|
||||
// 2026-07-30 확정 (실제 프로덕션 DB 조회로 검증): 리네임 전 이 마이그레이션은 CREATE TABLE
|
||||
// 안에 MySQL 전용 인라인 "INDEX name (cols)" 구문을 사용해 PostgreSQL에서 문법 오류로
|
||||
// 실패했고, quantengine.schemaversions(DbUp 저널)에도 전혀 기록되어 있지 않았다 — 아래 3개
|
||||
// 테이블은 프로덕션에 실제로 존재하지 않음을 확인했다. 인라인 INDEX 구문은 별도 CREATE INDEX
|
||||
// 문으로 수정했고, V003→V9 리네임으로 실행 순서 문제도 해결했으므로 다음 배포 시 DbUp가 이
|
||||
// 마이그레이션을 최초로 실행해 아래 3개 테이블을 생성할 것이다.
|
||||
// =============================================================================
|
||||
|
||||
Table quantengine.kis_collection_runs_audit {
|
||||
@@ -557,8 +561,10 @@ Table quantengine.kis_collection_errors_audit {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// V004: 3NF Normalization / Star Schema (quantengine schema, Adapter 패턴으로
|
||||
// 기존 kis_collection_snapshots와 병행 운영 — 마이그레이션 자체 주석에 명시됨)
|
||||
// V10: 3NF Normalization / Star Schema (quantengine schema, 파일:
|
||||
// V10__Normalize_Snapshots_Schema.sql, 원래 이름 V004_normalize_snapshots_schema.sql,
|
||||
// 2026-07-30에 V10로 리네임 — 위 헤더 참고. Adapter 패턴으로 기존
|
||||
// kis_collection_snapshots와 병행 운영 — 마이그레이션 자체 주석에 명시됨)
|
||||
// =============================================================================
|
||||
|
||||
Table quantengine.stocks {
|
||||
@@ -611,14 +617,21 @@ Table quantengine.kis_collection_snapshots_v2 {
|
||||
// =============================================================================
|
||||
// V8: PostgreSQL History-First Operating Model (quantengine schema)
|
||||
//
|
||||
// ⚠️ 스키마 충돌 주의: 아래 4개 테이블(market_raw_history, factor_version_history,
|
||||
// factor_output_history, decision_result_history)은 engine_history 스키마(V3, 위 참고)에
|
||||
// 이미 동일한 이름으로 존재한다. 컬럼 구조를 대조한 결과 같은 테이블의 재적용이 아니라
|
||||
// 서로 다른 두 가지 설계다:
|
||||
// - engine_history.*: EAV형 원본 관측 이력 (field_name/field_value 페어)
|
||||
// - quantengine.*(이 섹션): OHLCV 와이드 테이블 / 팩터ID-스코어 구조
|
||||
// 둘 다 실제 마이그레이션 파일에 존재하므로 DBML에는 두 스키마 버전을 모두 남긴다.
|
||||
// 어느 쪽이 정본인지, 혹은 통합이 필요한지는 별도 아키텍처 결정 필요 (이번 작업 범위 밖).
|
||||
// ⚠️ 동명이의 테이블 (통합 대상 아님, 2026-07-30 코드 조사로 확정): 아래 4개 테이블
|
||||
// (market_raw_history, factor_version_history, factor_output_history,
|
||||
// decision_result_history)은 engine_history 스키마(V3, 위 참고)에도 같은 이름으로 존재하지만,
|
||||
// 마이그레이션 버그도 아니고 죽은 코드도 아니다 — 둘 다 실제로 읽고 쓰는 라이브 코드가 있는
|
||||
// 서로 다른 두 모델이다:
|
||||
// - engine_history.*: PostgresqlHistoryStore.AppendAsync() + HistoryIngestionService가
|
||||
// 쓰는 범용 append-only 이력 저장소 (EAV형 원본 관측 이력)
|
||||
// - quantengine.*(이 섹션): PostgresqlHistoryStore의 RecordWaterfallExecutionAsync 등
|
||||
// + Admin 엔드포인트(BulkInsertMarketExcelEndpoint, UpdateFactorThresholdEndpoint,
|
||||
// ExportStreamingFactorOlapEndpoint)가 쓰는 신형 구조화 운영 모델 (OHLCV 와이드 테이블 /
|
||||
// 팩터ID-스코어 구조)
|
||||
// 결론: 둘 다 유지해야 한다. 다만 같은 개념에 같은 테이블명을 두 스키마에서 쓰는 것 자체가
|
||||
// 향후 개발자가 착각하기 쉬우므로(예: quantengine.market_raw_history에 쓸 걸 실수로
|
||||
// engine_history.market_raw_history에 씀), 신규 코드 작성 시 스키마를 명시적으로 지정하고
|
||||
// 반드시 어느 모델을 쓰는지 주석으로 남길 것.
|
||||
// =============================================================================
|
||||
|
||||
Table quantengine.market_raw_history {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Linq;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
|
||||
namespace QuantEngine.Core.Tests;
|
||||
|
||||
public class MigrationScriptNameComparerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sorts_DoubleDigit_Version_After_SingleDigit_Versions()
|
||||
{
|
||||
var scripts = new[]
|
||||
{
|
||||
"QuantEngine.Infrastructure.Migrations.V10__Normalize_Snapshots_Schema.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V2__Add_Kis_Collections.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V9__Add_Audit_Trail_Tables.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V1__Initial_Schema.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V8__PostgreSQL_History_First_Schema.sql",
|
||||
};
|
||||
|
||||
var sorted = scripts.OrderBy(s => s, new MigrationScriptNameComparer()).ToArray();
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"QuantEngine.Infrastructure.Migrations.V1__Initial_Schema.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V2__Add_Kis_Collections.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V8__PostgreSQL_History_First_Schema.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V9__Add_Audit_Trail_Tables.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V10__Normalize_Snapshots_Schema.sql",
|
||||
}, sorted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regression_ZeroPadded_Script_No_Longer_Sorts_Before_V1()
|
||||
{
|
||||
// This is the exact bug that shipped 2026-07-24: "V003_..." sorted before "V1__..."
|
||||
// under plain ordinal comparison, so a migration with unmet table dependencies ran
|
||||
// first. The comparer must treat "V003" as version 3, landing it between V2 and V4.
|
||||
var scripts = new[]
|
||||
{
|
||||
"QuantEngine.Infrastructure.Migrations.V003_add_audit_trail_tables.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V1__Initial_Schema.sql",
|
||||
"QuantEngine.Infrastructure.Migrations.V4__Add_Initial_Admin.sql",
|
||||
};
|
||||
|
||||
var sorted = scripts.OrderBy(s => s, new MigrationScriptNameComparer()).ToArray();
|
||||
|
||||
Assert.Equal("QuantEngine.Infrastructure.Migrations.V1__Initial_Schema.sql", sorted[0]);
|
||||
Assert.Equal("QuantEngine.Infrastructure.Migrations.V003_add_audit_trail_tables.sql", sorted[1]);
|
||||
Assert.Equal("QuantEngine.Infrastructure.Migrations.V4__Add_Initial_Admin.sql", sorted[2]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user