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.
pull/154/head
VinPropane 2026-06-03 22:37:55 -04:00
parent ab315f326b
commit 5f1244843c
4 changed files with 43 additions and 27 deletions

View File

@ -0,0 +1,34 @@
using System.Collections.Concurrent;
using Json.Schema;
namespace NeonSprawl.Server;
/// <summary>
/// Thread-safe registration for <see cref="SchemaRegistry.Global"/> (JsonSchema.Net registry is not concurrent).
/// Used by content catalog loaders when parallel integration tests spin up multiple hosts.
/// </summary>
internal static class CatalogSchemaRegistry
{
private static readonly Lock RegisterLock = new();
private static readonly ConcurrentDictionary<string, JsonSchema> SchemasByPath =
new(StringComparer.Ordinal);
/// <summary>Parses <paramref name="schemaFilePath"/> once per process and registers it under a global lock.</summary>
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;
}
}
}

View File

@ -45,12 +45,8 @@ public static class RecipeDefinitionCatalogLoader
if (errors.Count > 0) if (errors.Count > 0)
ThrowIfAny(errors); ThrowIfAny(errors);
var ioSchemaText = File.ReadAllText(recipeIoRowSchemaPath); var ioSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(recipeIoRowSchemaPath);
var defSchemaText = File.ReadAllText(recipeDefSchemaPath); var defSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(recipeDefSchemaPath);
var ioSchema = JsonSchema.FromText(ioSchemaText);
SchemaRegistry.Global.Register(ioSchema);
var defSchema = JsonSchema.FromText(defSchemaText);
SchemaRegistry.Global.Register(defSchema);
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };

View File

@ -44,10 +44,8 @@ public static class RewardTableDefinitionCatalogLoader
if (errors.Count > 0) if (errors.Count > 0)
ThrowIfAny(errors); ThrowIfAny(errors);
var grantRowSchema = JsonSchema.FromText(File.ReadAllText(rewardGrantRowSchemaPath)); var grantRowSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
SchemaRegistry.Global.Register(grantRowSchema); var tableSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(rewardTableSchemaPath);
var tableSchema = JsonSchema.FromText(File.ReadAllText(rewardTableSchemaPath));
SchemaRegistry.Global.Register(tableSchema);
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };

View File

@ -9,7 +9,6 @@ namespace NeonSprawl.Server.Game.Quests;
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113).</summary> /// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113).</summary>
public static class QuestDefinitionCatalogLoader public static class QuestDefinitionCatalogLoader
{ {
private static readonly Lock SchemaLoadLock = new();
private static JsonSchema? _questObjectiveDefSchema; private static JsonSchema? _questObjectiveDefSchema;
private static JsonSchema? _questStepDefSchema; private static JsonSchema? _questStepDefSchema;
private static JsonSchema? _questDefSchema; private static JsonSchema? _questDefSchema;
@ -352,23 +351,12 @@ public static class QuestDefinitionCatalogLoader
string questStepDefSchemaPath, string questStepDefSchemaPath,
string questDefSchemaPath) string questDefSchemaPath)
{ {
lock (SchemaLoadLock) if (_questDefSchema is not null)
{ return;
if (_questDefSchema is not null)
return;
var objectiveSchema = JsonSchema.FromText(File.ReadAllText(questObjectiveDefSchemaPath)); _questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath);
SchemaRegistry.Global.Register(objectiveSchema); _questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath);
_questObjectiveDefSchema = objectiveSchema; _questDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questDefSchemaPath);
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;
}
} }
private static void ThrowIfAny(List<string> errors) private static void ThrowIfAny(List<string> errors)