diff --git a/Libraries/SPTarkov.Server.Core/Models/Common/MongoId.cs b/Libraries/SPTarkov.Server.Core/Models/Common/MongoId.cs index 2b133f019..72d28b223 100644 --- a/Libraries/SPTarkov.Server.Core/Models/Common/MongoId.cs +++ b/Libraries/SPTarkov.Server.Core/Models/Common/MongoId.cs @@ -5,7 +5,7 @@ namespace SPTarkov.Server.Core.Models.Common; /// -/// Represents a 12-byte MongoDB-style ObjectId, consisting of: +/// Represents a 12- MongoDB-style ObjectId, consisting of: /// /// 4-byte timestamp (seconds since Unix epoch, big-endian) /// 3-byte machine identifier @@ -39,7 +39,7 @@ namespace SPTarkov.Server.Core.Models.Common; private readonly int _pidAndIncrement; private static readonly int _machine = BitConverter.ToInt32(RandomNumberGenerator.GetBytes(4), 0) & 0xFFFFFF; - private static readonly short _pid = (short)Environment.ProcessId; + private static readonly short _pid = (short) Environment.ProcessId; private static int _increment = RandomNumberGenerator.GetInt32(0, 0xFFFFFF); public bool IsEmpty @@ -53,25 +53,25 @@ public bool IsEmpty /// public MongoId() { - var timestamp = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var timestamp = (int) DateTimeOffset.UtcNow.ToUnixTimeSeconds(); Span bytes = stackalloc byte[12]; // timestamp (4 bytes, big-endian) BinaryPrimitives.WriteInt32BigEndian(bytes, timestamp); // machine ID (3 bytes) - bytes[4] = (byte)(_machine >> 16); - bytes[5] = (byte)(_machine >> 8); - bytes[6] = (byte)_machine; + bytes[4] = (byte) (_machine >> 16); + bytes[5] = (byte) (_machine >> 8); + bytes[6] = (byte) _machine; // PID (2 bytes) BinaryPrimitives.WriteInt16BigEndian(bytes[7..9], _pid); // increment (3 bytes, big-endian) var inc = Interlocked.Increment(ref _increment) & 0xFFFFFF; - bytes[9] = (byte)(inc >> 16); - bytes[10] = (byte)(inc >> 8); - bytes[11] = (byte)inc; + bytes[9] = (byte) (inc >> 16); + bytes[10] = (byte) (inc >> 8); + bytes[11] = (byte) inc; // pack into fields (avoids array allocations later) _timestampAndMachine = BitConverter.ToInt64(bytes); @@ -82,8 +82,7 @@ public MongoId(string? hex) { if (string.IsNullOrEmpty(hex) || hex == "000000000000000000000000") { - _timestampAndMachine = 0; - _pidAndIncrement = 0; + this = default; return; } @@ -93,26 +92,28 @@ public MongoId(string? hex) } Span bytes = stackalloc byte[12]; - Span chars = stackalloc char[24]; - hex.AsSpan().CopyTo(chars); - for (var i = 0; i < 12; i++) { var hi = HexCharToValue(hex[2 * i]); - var lo = HexCharToValue(hex[2 * i + 1]); + var lo = HexCharToValue(hex[(2 * i) + 1]); if (hi == -1 || lo == -1) { throw new FormatException("ObjectId contains invalid hex characters."); } - bytes[i] = (byte)((hi << 4) | lo); + bytes[i] = (byte) ((hi << 4) | lo); } _timestampAndMachine = BitConverter.ToInt64(bytes); _pidAndIncrement = BitConverter.ToInt32(bytes[8..]); } + /// + /// Converts a hexadecimal character into its corresponding integer nibble value. + /// + /// The hex character to evaluate (0-9, a-f, A-F). + /// An integer value from 0 to 15 if the character is valid hex; otherwise, -1. private static int HexCharToValue(char c) { return c >= '0' && c <= '9' ? c - '0' @@ -121,30 +122,74 @@ private static int HexCharToValue(char c) : -1; } + /// + /// Converts an integer nibble value into its corresponding lowercase hexadecimal character representation. + /// + /// The nibble value to convert (0-15). + /// A lowercase hexadecimal character representing the specified value. + private static char HexValueToChar(int value) + { + return (char) (value < 10 ? value + '0' : value - 10 + 'a'); + } + /// /// Returns the MongoId as a 24-character lowercase hexadecimal string. /// public override string ToString() { - if (_timestampAndMachine == 0 && _pidAndIncrement == 0) + if (IsEmpty) { return string.Empty; } - Span bytes = stackalloc byte[12]; - BitConverter.TryWriteBytes(bytes, _timestampAndMachine); - BitConverter.TryWriteBytes(bytes[8..], _pidAndIncrement); - return Convert.ToHexString(bytes).ToLowerInvariant(); + return string.Create(24, this, static (chars, state) => + { + Span bytes = stackalloc byte[12]; + BitConverter.TryWriteBytes(bytes, state._timestampAndMachine); + BitConverter.TryWriteBytes(bytes[8..], state._pidAndIncrement); + + for (var i = 0; i < 12; i++) + { + var b = bytes[i]; + chars[i * 2] = HexValueToChar(b >> 4); + chars[(i * 2) + 1] = HexValueToChar(b & 0x0F); + } + }); } - public bool Equals(MongoId? other) + /// + /// Tries to format the current instance into the provided character span. + /// + /// The destination span. Must be at least 24 characters long. + /// When this method returns, contains the number of characters written. + /// if the formatting was successful; otherwise, . + public bool TryFormat(Span destination, out int charsWritten) { - if (other is null) + if (destination.Length < 24) { + charsWritten = 0; return false; } - return _timestampAndMachine == other.Value._timestampAndMachine && _pidAndIncrement == other.Value._pidAndIncrement; + if (IsEmpty) + { + charsWritten = 0; + return true; + } + + Span bytes = stackalloc byte[12]; + BitConverter.TryWriteBytes(bytes, _timestampAndMachine); + BitConverter.TryWriteBytes(bytes[8..], _pidAndIncrement); + + for (var i = 0; i < 12; i++) + { + var b = bytes[i]; + destination[i * 2] = HexValueToChar(b >> 4); + destination[(i * 2) + 1] = HexValueToChar(b & 0x0F); + } + + charsWritten = 24; + return true; } /// @@ -170,7 +215,7 @@ public bool Equals(string? other) return false; } - bytes[i] = (byte)((hi << 4) | lo); + bytes[i] = (byte) ((hi << 4) | lo); } var a = BitConverter.ToInt64(bytes); @@ -179,6 +224,11 @@ public bool Equals(string? other) return _timestampAndMachine == a && _pidAndIncrement == b; } + /// + /// Validates whether the specified string represents a valid MongoDB ObjectId format. + /// + /// The string representation of the identifier to validate. + /// if the string satisfies format constraints; otherwise, . public static bool IsValidMongoId(string stringToCheck) { return stringToCheck.IsValidMongoId(); @@ -232,8 +282,12 @@ public override int GetHashCode() return HashCode.Combine(_timestampAndMachine, _pidAndIncrement); } + /// + /// Returns an empty instance with all internal bits initialized to zero. + /// + /// A default initialized . public static MongoId Empty() { - return new MongoId("000000000000000000000000"); + return default; } } diff --git a/Libraries/SPTarkov.Server.Core/Utils/Json/Converters/StringToMongoIdConverter.cs b/Libraries/SPTarkov.Server.Core/Utils/Json/Converters/StringToMongoIdConverter.cs index b1a674750..67bd9b8e9 100644 --- a/Libraries/SPTarkov.Server.Core/Utils/Json/Converters/StringToMongoIdConverter.cs +++ b/Libraries/SPTarkov.Server.Core/Utils/Json/Converters/StringToMongoIdConverter.cs @@ -4,7 +4,7 @@ namespace SPTarkov.Server.Core.Utils.Json.Converters; -public class StringToMongoIdConverter : JsonConverter +public sealed class StringToMongoIdConverter : JsonConverter { public override MongoId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -13,12 +13,20 @@ public override MongoId Read(ref Utf8JsonReader reader, Type typeToConvert, Json return new MongoId(reader.GetString()); } - throw new JsonException(); + throw new JsonException($"The JsonTokenType was not of type string, it was: {reader.TokenType}"); } public override void Write(Utf8JsonWriter writer, MongoId mongoId, JsonSerializerOptions options) { - JsonSerializer.Serialize(writer, mongoId.ToString(), options); + Span buffer = stackalloc char[24]; + if (mongoId.TryFormat(buffer, out var charsWritten)) + { + writer.WriteStringValue(buffer[..charsWritten]); + } + else + { + throw new JsonException("Failed to format MongoId to stack buffer."); + } } // Deserialize MongoId as a dictionary key @@ -30,6 +38,21 @@ public override MongoId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeT // Serialize MongoId as a dictionary key public override void WriteAsPropertyName(Utf8JsonWriter writer, MongoId value, JsonSerializerOptions options) { - writer.WritePropertyName(value.ToString()); + Span buffer = stackalloc char[24]; + if (value.TryFormat(buffer, out var charsWritten)) + { + if (charsWritten == 0) + { + writer.WritePropertyName(string.Empty); + } + else + { + writer.WritePropertyName(buffer[..charsWritten]); + } + } + else + { + throw new JsonException("Failed to format MongoId to stack buffer."); + } } } diff --git a/Testing/UnitTests/Tests/MongoIDTests.cs b/Testing/UnitTests/Tests/MongoIDTests.cs index f08e0b716..4804b8344 100644 --- a/Testing/UnitTests/Tests/MongoIDTests.cs +++ b/Testing/UnitTests/Tests/MongoIDTests.cs @@ -1,16 +1,40 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; using NUnit.Framework; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Models.Common; +using SPTarkov.Server.Core.Utils.Json.Converters; namespace UnitTests.Tests; [TestFixture] public class MongoIDTests { + private const string ValidHex = "507f1f77bcf86cd799439011"; + private const string ZeroHexZeroHex = "000000000000000000000000"; + + private const string TestHex = "507f1f77bcf86cd799439011"; + private MongoId _testId; + private JsonSerializerOptions? _options; + [OneTimeSetUp] - public void Initialize() { } + public void Initialize() + { + _testId = new MongoId(TestHex); + _options = new JsonSerializerOptions() + { + // This is required for JSONC support + ReadCommentHandling = JsonCommentHandling.Skip, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + NewLine = "\n", + }; + } [Test] public void GenerateTest() @@ -28,6 +52,237 @@ public void GenerateTest() } } + [Test] + public void Constructor_EmptyAndNull_ReturnsDefaultInstance() + { + // Arrange & Act + var fromNull = new MongoId(null); + var fromEmpty = new MongoId(string.Empty); + var fromZeroes = new MongoId(ZeroHexZeroHex); + var defaultInstance = default(MongoId); + + // Assert + Assert.Multiple(() => + { + Assert.That(fromNull.IsEmpty, Is.True); + Assert.That(fromEmpty.IsEmpty, Is.True); + Assert.That(fromZeroes.IsEmpty, Is.True); + Assert.That(fromNull, Is.EqualTo(defaultInstance)); + Assert.That(fromEmpty, Is.EqualTo(defaultInstance)); + Assert.That(fromZeroes, Is.EqualTo(defaultInstance)); + }); + } + + [Test] + public void Constructor_ValidHex_ParsesCorrectly() + { + // Arrange & Act + var id = new MongoId(ValidHex); + + // Assert + Assert.Multiple(() => + { + Assert.That(id.IsEmpty, Is.False); + Assert.That(id.ToString(), Is.EqualTo(ValidHex)); + }); + } + + [TestCase("507f1f77bcf86cd79943901")] // 23 chars + [TestCase("507f1f77bcf86cd7994390112")] // 25 chars + public void Constructor_InvalidLength_ThrowsArgumentException(string invalidLengthHex) + { + // Act & Assert + Assert.Throws(() => _ = new MongoId(invalidLengthHex)); + } + + [TestCase("507f1f77bcf86cd79943901g")] // 'g' is out of range + [TestCase("507f1f77bcf86cd79943901X")] // 'X' is out of range + [TestCase("507f1f77bcf86cd79943901-")] // Special character + public void Constructor_InvalidHexCharacters_ThrowsFormatException(string invalidCharHex) + { + // Act & Assert + Assert.Throws(() => _ = new MongoId(invalidCharHex)); + } + + [Test] + public void ParameterlessConstructor_GeneratesUniqueSequentialIds() + { + // Arrange & Act + var id1 = new MongoId(); + var id2 = new MongoId(); + + // Assert + Assert.Multiple(() => + { + Assert.That(id1.IsEmpty, Is.False); + Assert.That(id2.IsEmpty, Is.False); + Assert.That(id1, Is.Not.EqualTo(id2)); + }); + } + + [Test] + public void Empty_ReturnsDefaultStruct() + { + // Arrange & Act + var empty = MongoId.Empty(); + + // Assert + Assert.Multiple(() => + { + Assert.That(empty.IsEmpty, Is.True); + Assert.That(empty, Is.EqualTo(default(MongoId))); + Assert.That(empty.ToString(), Is.EqualTo(string.Empty)); + }); + } + + [Test] + public void Equals_StateEquality_ReturnsTrueForMatchingData() + { + // Arrange + var id1 = new MongoId(ValidHex); + var id2 = new MongoId(ValidHex); + var diff = new MongoId(); + + // Assert + Assert.Multiple(() => + { + Assert.That(id1.Equals(id2), Is.True); + Assert.That(id1 == id2, Is.True); + Assert.That(id1 != id2, Is.False); + + Assert.That(id1.Equals(diff), Is.False); + Assert.That(id1 == diff, Is.False); + Assert.That(id1 != diff, Is.True); + }); + } + + [Test] + public void Equals_StringComparison_ValidatesCorrectly() + { + // Arrange + var id = new MongoId(ValidHex); + var mixedCaseHex = "507F1F77BCF86CD799439011"; + + // Assert + Assert.Multiple(() => + { + Assert.That(id.Equals(ValidHex), Is.True); + Assert.That(id.Equals(mixedCaseHex), Is.True); // Hex parsing handles uppercase + Assert.That(id.Equals("invalidstringlen"), Is.False); + Assert.That(id.Equals((string?) null), Is.False); + }); + } + + [Test] + public void GetHashCode_IdenticalObjects_YieldIdenticalHash() + { + // Arrange + var id1 = new MongoId(ValidHex); + var id2 = new MongoId(ValidHex); + + // Act & Assert + Assert.That(id1.GetHashCode(), Is.EqualTo(id2.GetHashCode())); + } + + [Test] + public void ImplicitOperators_ConvertCorrectlyBetweenStringAndMongoId() + { + // Arrange & Act + MongoId id = ValidHex; // Implicit conversion from string + string hexStr = id; // Implicit conversion to string + + // Assert + Assert.Multiple(() => + { + Assert.That(id.IsEmpty, Is.False); + Assert.That(hexStr, Is.EqualTo(ValidHex)); + }); + } + + [Test] + public void ToString_OutputScenarios_BehavesCorrectly() + { + // Arrange + var upperCaseInput = "507F1F77BCF86CD799439011"; + var expectedLower = "507f1f77bcf86cd799439011"; + + var idFromUpper = new MongoId(upperCaseInput); + var emptyId = MongoId.Empty(); + + // Act + var outputFromParsed = idFromUpper.ToString(); + var outputFromEmpty = emptyId.ToString(); + + // Assert + Assert.Multiple(() => + { + // Verifies zero-allocation path transforms uppercase inputs to lowercase outputs + Assert.That(outputFromParsed, Is.EqualTo(expectedLower)); + + // Verifies empty instance strictly yields string.Empty + Assert.That(outputFromEmpty, Is.EqualTo(string.Empty)); + }); + } + + [Test] + public void Write_ValidMongoId_WritesCorrectJsonString() + { + // Arrange + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + var converter = new StringToMongoIdConverter(); + + // Act + writer.WriteStartObject(); + writer.WritePropertyName("id"); + converter.Write(writer, _testId, _options!); + writer.WriteEndObject(); + writer.Flush(); + + // Assert + var jsonOutput = Encoding.UTF8.GetString(stream.ToArray()); + Assert.That(jsonOutput, Is.EqualTo($"{{\"id\":\"{TestHex}\"}}")); + } + + [Test] + public void WriteAsPropertyName_ValidMongoId_WritesCorrectPropertyName() + { + // Arrange + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + var converter = new StringToMongoIdConverter(); + + // Act + writer.WriteStartObject(); + converter.WriteAsPropertyName(writer, _testId, _options!); + writer.WriteBooleanValue(true); + writer.WriteEndObject(); + writer.Flush(); + + // Assert + var jsonOutput = Encoding.UTF8.GetString(stream.ToArray()); + Assert.That(jsonOutput, Is.EqualTo($"{{\"{TestHex}\":true}}")); + } + + [Test] + public void Write_EmptyMongoId_WritesZeroHexString() + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + var converter = new StringToMongoIdConverter(); + var emptyId = MongoId.Empty(); + + writer.WriteStartObject(); + writer.WritePropertyName("id"); + converter.Write(writer, emptyId, _options!); + writer.WriteEndObject(); + writer.Flush(); + + var jsonOutput = Encoding.UTF8.GetString(stream.ToArray()); + Assert.That(jsonOutput, Is.EqualTo("{\"id\":\"\"}")); + Console.WriteLine($"Output: {jsonOutput}"); + } + [TestCase("677ddb67406e9918a0264bbz", false, "677ddb67406e9918a0264bbz contains invalid char `z`, but result was true")] [TestCase("677ddb67406e9918a0264bbcc", false, "677ddb67406e9918a0264bbcc is 25 characters, but result was true")] [TestCase("677ddb67406e9918a0264bbc", true, "IsValidMongoId() `677ddb67406e9918a0264bbc` is a valid mongoId, but result was false")] @@ -40,30 +295,39 @@ public void IsValidMongoIdTest(string mongoId, bool passes, string failMessage) [Test] public void MultiThreadedMongoIDGenerationTest() { - var concurrentBag = new ConcurrentBag(); - var random = new Random(); - var stopwatch = new Stopwatch(); - stopwatch.Start(); + const int targetCount = 1_000_000; + + // Store raw structs to eliminate heap allocations during generation + var concurrentBag = new ConcurrentBag(); + + var stopwatch = Stopwatch.StartNew(); Parallel.For( 0, - 1000, - i => + targetCount, + _ => { - Thread.Sleep(random.Next(0, 10)); var mongoId = new MongoId(); concurrentBag.Add(mongoId); } ); stopwatch.Stop(); - Console.WriteLine($"Elapsed time: {stopwatch.ElapsedMilliseconds} ms"); - var uniqueCount = concurrentBag.Distinct().Count(); + Console.WriteLine($"Generated {targetCount:N0} IDs in: {stopwatch.ElapsedMilliseconds} ms"); + + // Verify uniqueness with minimum allocations var totalCount = concurrentBag.Count; - Assert.AreEqual( - totalCount, - uniqueCount, - $"Expected all generated MongoId's to be unique, but found: {totalCount - uniqueCount} duplicates." - ); + + // Explicitly size the HashSet capacity to prevent resizing overhead + var uniqueSet = new HashSet(totalCount); + foreach (var id in concurrentBag) + { + uniqueSet.Add(id); + } + + var uniqueCount = uniqueSet.Count; + + Assert.That(uniqueCount, Is.EqualTo(totalCount), + $"Expected all generated MongoId's to be unique, but found: {totalCount - uniqueCount} duplicates."); } }