Removes the per-entity handoff from the freeze entirely. GenericEntityPersistence publishes 4096-slot ranges over its dictionary's backing entries array, and workers serialize occupied slots (value != null) directly via a ShadowEntry<TValue> struct that mirrors the runtime's private Dictionary Entry layout. Safe because the dictionary is frozen during Saving (mutations divert to the pending safety queues). The layout is proven at startup before any code reads through it: validation measures the true Entry stride via precise allocation accounting (so all shadow reads are guaranteed in-bounds), then compares every key and value of a churned, resized, freelist-exercised dictionary reading value slots as raw pointer bits only - never materializing a managed reference until the layout is proven. If a future runtime changes Dictionary internals, validation fails and saves fall back to the enumerate-and-push path with a logged warning. The main thread now joins the drain through an inline (threadless) worker after publishing work, instead of idling while the thread workers finish - worth a full worker share on the freeze and proportionally more on low-core hosts. Measured through the real pipeline classes (24 cores, 10M entities, 1.7GB, dense 2-byte write profile): publish cost drops from ~55ms to ~0.1ms, the freeze is now bound by pure serialize throughput at ~99ms steady state (vs ~740ms before this branch, ~7.5x), and the first save after boot drops from ~468ms to ~139ms because fine-grained ranges self-balance without needing size estimates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
118 lines
3.7 KiB
C#
118 lines
3.7 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Xunit;
|
|
|
|
namespace Server.Tests;
|
|
|
|
public class ShadowDictionaryEntriesTests
|
|
{
|
|
private class TestEntity : ISerializable
|
|
{
|
|
public TestEntity(Serial serial) => Serial = serial;
|
|
|
|
public Serial Serial { get; }
|
|
public DateTime Created { get; set; }
|
|
public bool Deleted => false;
|
|
public byte SerializedThread { get; set; }
|
|
public int SerializedPosition { get; set; }
|
|
public int SerializedLength { get; set; }
|
|
|
|
public void Delete()
|
|
{
|
|
}
|
|
|
|
public void Serialize(IGenericWriter writer)
|
|
{
|
|
writer.Write(Serial);
|
|
writer.Write(0xC0FFEE);
|
|
}
|
|
|
|
public void Deserialize(IGenericReader reader)
|
|
{
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void RuntimeLayoutIsSupported()
|
|
{
|
|
// If this fails on a runtime upgrade, saves still work via the fallback path,
|
|
// but the parallel iteration fast path is silently lost — this test makes it loud.
|
|
Assert.True(ShadowDictionaryEntries.Supported);
|
|
}
|
|
|
|
[Fact]
|
|
public void SerializeRangeCoversExactlyTheLiveEntities()
|
|
{
|
|
var persistence = new GenericEntityPersistence<TestEntity>("ShadowTest", 1000, 1, 0x7FFFFFFF);
|
|
|
|
try
|
|
{
|
|
var rng = new System.Random(0xBEEF);
|
|
var dict = persistence.EntitiesBySerial;
|
|
|
|
// Heavy churn: adds, removes, and re-adds to exercise freelist reuse and resizes,
|
|
// leaving free slots scattered through the entries array.
|
|
var serials = new List<Serial>();
|
|
for (var i = 0; i < 50_000; i++)
|
|
{
|
|
var serial = (Serial)(uint)rng.Next(1, int.MaxValue);
|
|
if (dict.TryAdd(serial, new TestEntity(serial)))
|
|
{
|
|
serials.Add(serial);
|
|
}
|
|
|
|
if (i % 4 == 3)
|
|
{
|
|
var index = rng.Next(serials.Count);
|
|
dict.Remove(serials[index]);
|
|
serials.RemoveAt(index);
|
|
}
|
|
}
|
|
|
|
Assert.True(persistence.TrySnapshotEntries(out var slotCount));
|
|
Assert.True(slotCount >= dict.Count);
|
|
|
|
var source = (ISlotRangeSource)persistence;
|
|
var writer = new BufferWriter(new byte[dict.Count * 16], true);
|
|
|
|
// Serialize in worker-sized slices, like the drain does.
|
|
var serialized = 0;
|
|
for (var offset = 0; offset < slotCount; offset += 4096)
|
|
{
|
|
serialized += source.SerializeRange(writer, 3, offset, Math.Min(4096, slotCount - offset));
|
|
}
|
|
|
|
Assert.Equal(dict.Count, serialized);
|
|
|
|
// Every live entity was stamped exactly once with a coherent span.
|
|
foreach (var (serial, entity) in dict)
|
|
{
|
|
Assert.Equal(3, entity.SerializedThread);
|
|
Assert.Equal(8, entity.SerializedLength); // serial + int
|
|
|
|
var span = writer.Buffer.AsSpan(entity.SerializedPosition, entity.SerializedLength);
|
|
Assert.Equal(serial, (Serial)System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(span));
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
persistence.Unregister();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotFailsGracefullyOnEmptyDictionary()
|
|
{
|
|
var persistence = new GenericEntityPersistence<TestEntity>("ShadowTestEmpty", 1001, 1, 0x7FFFFFFF);
|
|
|
|
try
|
|
{
|
|
Assert.False(persistence.TrySnapshotEntries(out var slotCount));
|
|
Assert.Equal(0, slotCount);
|
|
}
|
|
finally
|
|
{
|
|
persistence.Unregister();
|
|
}
|
|
}
|
|
}
|