From 8ed2bcf56f2d94b1731f2be5e1ae1a68e7c4b7fd Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 7 Aug 2026 23:36:08 +0900 Subject: [PATCH] fix: Dapper underscore-mapping race condition affects AuditSql too, not just TradeSql The static-ctor guard added to TradeSql in the previous commit was a symptom fix. Confirmed the same bug independently affects AuditSql: running the Compliance test filter in isolation (no other class that happens to touch a BuildingBlocks type first) reproduced the identical failure mode - every snake_case column (event_type, purge_status, ...) silently mapped to null. Root cause: KArtSell.BuildingBlocks.Data.DapperBootstrap's [ModuleInitializer] only runs once that assembly is actually loaded, and a `using` directive for a BuildingBlocks namespace does not force that load - only an executed reference to one of its types does. Any Sql class that never actually touches a BuildingBlocks type at runtime is exposed, and this is a property of *when* a given test/request happens to run relative to everything else in the process, not of any one class. Replaced the ad-hoc TradeSql static ctor with one [ModuleInitializer] per module assembly (KArtSell.Modules.ModelOperations, KArtSell.Modules.SignalEngine). Every Sql/reader class lives inside its own module's assembly, so a module initializer there is guaranteed to run before any of them are used, independent of BuildingBlocks or load order. Verified both KArtSell.Integration.Tests.Compliance and .TradeExecution now pass 100% run in full isolation, not just as part of the full suite. Co-Authored-By: Claude Haiku 4.5 --- .../DapperMappingBootstrap.cs | 25 +++++++++++++++++++ .../TradeExecution/TradeSql.cs | 10 -------- .../DapperMappingBootstrap.cs | 21 ++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 src/KArtSell.Modules.ModelOperations/DapperMappingBootstrap.cs create mode 100644 src/KArtSell.Modules.SignalEngine/DapperMappingBootstrap.cs diff --git a/src/KArtSell.Modules.ModelOperations/DapperMappingBootstrap.cs b/src/KArtSell.Modules.ModelOperations/DapperMappingBootstrap.cs new file mode 100644 index 00000000..55943901 --- /dev/null +++ b/src/KArtSell.Modules.ModelOperations/DapperMappingBootstrap.cs @@ -0,0 +1,25 @@ +using System.Runtime.CompilerServices; + +namespace KArtSell.Modules.ModelOperations; + +/// +/// KArtSell.BuildingBlocks.Data.DapperBootstrap sets Dapper's snake_case-to-PascalCase column +/// mapping via its own [ModuleInitializer], but that only fires once that assembly is actually +/// loaded into the process. Several Sql classes in this module (e.g. AuditSql, TradeSql) only +/// have a `using` for a BuildingBlocks namespace without ever touching a type from it at +/// runtime, so under test isolation - or any host that queries this module before touching +/// BuildingBlocks - the load (and the mapping) can be skipped, silently nulling out every +/// snake_case column. Every Sql class in this assembly is defined here, so a module initializer +/// in this assembly is guaranteed to run before any of them are used, regardless of what else +/// has loaded. +/// +internal static class DapperMappingBootstrap +{ +#pragma warning disable CA2255 + [ModuleInitializer] +#pragma warning restore CA2255 + public static void Initialize() + { + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + } +} diff --git a/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs b/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs index b1df1a00..a33dc955 100644 --- a/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs +++ b/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs @@ -21,16 +21,6 @@ public class TradeSql : ITradeSql private readonly NpgsqlDataSource _dataSource; private readonly ILogger _logger; - static TradeSql() - { - // KArtSell.BuildingBlocks.Data.DapperBootstrap sets this via [ModuleInitializer], but that - // only fires once its assembly is actually loaded into the process. Nothing in this class - // references a BuildingBlocks type, so under test isolation (or any host that queries Trade - // before touching BuildingBlocks) that assembly load - and the mapping - can be skipped, - // silently nulling out every snake_case column (kis_order_id, sell_decision_id, ...). - Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; - } - public TradeSql(NpgsqlDataSource dataSource, ILogger logger) { _dataSource = dataSource; diff --git a/src/KArtSell.Modules.SignalEngine/DapperMappingBootstrap.cs b/src/KArtSell.Modules.SignalEngine/DapperMappingBootstrap.cs new file mode 100644 index 00000000..f7f6ce1f --- /dev/null +++ b/src/KArtSell.Modules.SignalEngine/DapperMappingBootstrap.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; + +namespace KArtSell.Modules.SignalEngine; + +/// +/// See KArtSell.Modules.ModelOperations.DapperMappingBootstrap for the full rationale: Dapper's +/// snake_case-to-PascalCase column mapping is a process-wide static flag set via +/// KArtSell.BuildingBlocks.Data.DapperBootstrap's [ModuleInitializer], which only fires once +/// that assembly is loaded. This mirrors it locally so every Sql/reader class defined in this +/// assembly is guaranteed the mapping is on before its first query, regardless of load order. +/// +internal static class DapperMappingBootstrap +{ +#pragma warning disable CA2255 + [ModuleInitializer] +#pragma warning restore CA2255 + public static void Initialize() + { + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + } +}