From 5f1244843cdc98d0396dc2fb248deaed959bc9a3 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 22:37:55 -0400 Subject: [PATCH] NEO-115: serialize JsonSchema registry registration for parallel tests. Recipe and reward-table loaders registered schemas without locking, corrupting SchemaRegistry.Global when xUnit spun up multiple WebApplicationFactory hosts concurrently in CI. --- .../CatalogSchemaRegistry.cs | 34 +++++++++++++++++++ .../Crafting/RecipeDefinitionCatalogLoader.cs | 8 ++--- .../RewardTableDefinitionCatalogLoader.cs | 6 ++-- .../Quests/QuestDefinitionCatalogLoader.cs | 22 +++--------- 4 files changed, 43 insertions(+), 27 deletions(-) create mode 100644 server/NeonSprawl.Server/CatalogSchemaRegistry.cs diff --git a/server/NeonSprawl.Server/CatalogSchemaRegistry.cs b/server/NeonSprawl.Server/CatalogSchemaRegistry.cs new file mode 100644 index 0000000..aa1d287 --- /dev/null +++ b/server/NeonSprawl.Server/CatalogSchemaRegistry.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using Json.Schema; + +namespace NeonSprawl.Server; + +/// +/// Thread-safe registration for (JsonSchema.Net registry is not concurrent). +/// Used by content catalog loaders when parallel integration tests spin up multiple hosts. +/// +internal static class CatalogSchemaRegistry +{ + private static readonly Lock RegisterLock = new(); + private static readonly ConcurrentDictionary SchemasByPath = + new(StringComparer.Ordinal); + + /// Parses once per process and registers it under a global lock. + public static JsonSchema GetOrRegisterFromFile(string schemaFilePath) + { + var fullPath = Path.GetFullPath(schemaFilePath); + if (SchemasByPath.TryGetValue(fullPath, out var cached)) + return cached; + + lock (RegisterLock) + { + if (SchemasByPath.TryGetValue(fullPath, out cached)) + return cached; + + var schema = JsonSchema.FromText(File.ReadAllText(fullPath)); + SchemaRegistry.Global.Register(schema); + SchemasByPath[fullPath] = schema; + return schema; + } + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs index b3a5c78..f43a8f4 100644 --- a/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs @@ -45,12 +45,8 @@ public static class RecipeDefinitionCatalogLoader if (errors.Count > 0) ThrowIfAny(errors); - var ioSchemaText = File.ReadAllText(recipeIoRowSchemaPath); - var defSchemaText = File.ReadAllText(recipeDefSchemaPath); - var ioSchema = JsonSchema.FromText(ioSchemaText); - SchemaRegistry.Global.Register(ioSchema); - var defSchema = JsonSchema.FromText(defSchemaText); - SchemaRegistry.Global.Register(defSchema); + var ioSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(recipeIoRowSchemaPath); + var defSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(recipeDefSchemaPath); var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs index 7a3b464..b510ede 100644 --- a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs @@ -44,10 +44,8 @@ public static class RewardTableDefinitionCatalogLoader if (errors.Count > 0) ThrowIfAny(errors); - var grantRowSchema = JsonSchema.FromText(File.ReadAllText(rewardGrantRowSchemaPath)); - SchemaRegistry.Global.Register(grantRowSchema); - var tableSchema = JsonSchema.FromText(File.ReadAllText(rewardTableSchemaPath)); - SchemaRegistry.Global.Register(tableSchema); + var grantRowSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath); + var tableSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(rewardTableSchemaPath); var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs index 7d099f0..f570220 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -9,7 +9,6 @@ namespace NeonSprawl.Server.Game.Quests; /// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113). public static class QuestDefinitionCatalogLoader { - private static readonly Lock SchemaLoadLock = new(); private static JsonSchema? _questObjectiveDefSchema; private static JsonSchema? _questStepDefSchema; private static JsonSchema? _questDefSchema; @@ -352,23 +351,12 @@ public static class QuestDefinitionCatalogLoader string questStepDefSchemaPath, string questDefSchemaPath) { - lock (SchemaLoadLock) - { - if (_questDefSchema is not null) - return; + if (_questDefSchema is not null) + return; - var objectiveSchema = JsonSchema.FromText(File.ReadAllText(questObjectiveDefSchemaPath)); - SchemaRegistry.Global.Register(objectiveSchema); - _questObjectiveDefSchema = objectiveSchema; - - var stepSchema = JsonSchema.FromText(File.ReadAllText(questStepDefSchemaPath)); - SchemaRegistry.Global.Register(stepSchema); - _questStepDefSchema = stepSchema; - - var defSchema = JsonSchema.FromText(File.ReadAllText(questDefSchemaPath)); - SchemaRegistry.Global.Register(defSchema); - _questDefSchema = defSchema; - } + _questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath); + _questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath); + _questDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questDefSchemaPath); } private static void ThrowIfAny(List errors)