feat: anchored-time infrastructure with a save-start anchor in idx v5 (#2585)
## Summary The save-stability infrastructure consumed by generator v3's `[AnchoredDateTime]`: anchored timestamps are written as **absolute values** and re-based once at load by the elapsed time since the save started — so downtime doesn't age them, and an unchanged entity serializes to identical bytes (the prerequisite for replacing delta-time encodings, which rewrite every entity on every save). ## Design - **`WriteAnchoredTime` / `ReadAnchoredTime`** on `IGenericWriter`/`IGenericReader`. The read side applies the reader's `AnchoredTimeShift`; `Min/MaxValue` sentinels pass through unshifted, and shifts saturate instead of overflowing. - **`World.SaveStartTime`** is stamped the moment the world freezes for a snapshot — one anchor for the entire save, no per-persistence skew. - **idx v5**: the anchor ticks sit in the header right after the version. The anchor travels with the file it re-anchors, so a single idx+bin pair restored from a backup is self-describing, and anchor presence is guaranteed by the same version gate as the record format — there is no separate anchor file to lose. - **The shift rides the reader instance** (`BufferReader`, `UnmanagedDataReader`, `BinaryFileReader` delegating), not a static — parallel per-persistence loads and ad-hoc restores each see their own file's anchor. idx v4 and older read with a zero shift. ## Scope Behavior-neutral: nothing serializes anchored values yet (`Item.DecayResetTime` and the `[DeltaDateTime]` field migrations come separately, with their own version bumps). Saves written from this branch are idx v5; loading v4/v3 saves is unchanged and remains pinned by the existing hand-written-header tests. ## Testing - Unit round-trips: exact with zero shift, shifted read, sentinel passthrough, saturation, Local→UTC normalization. - End-to-end through the real worker/segment-log pipeline: an anchored timestamp re-bases across a simulated two-hour downtime via the idx v5 header. - Full suites green: Server.Tests 835/835, UOContent.Tests 708/708 (including the existing v4/v3 idx loading tests).
This commit is contained in:
parent
541dbc5ac5
commit
126a10ce53
9 changed files with 279 additions and 5 deletions
189
Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs
Normal file
189
Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
public class AnchoredTimeTests
|
||||||
|
{
|
||||||
|
private static (BufferWriter Writer, Func<TimeSpan, IGenericReader> Read) CreateRoundTrip()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[64], true);
|
||||||
|
return (writer, shift => new BufferReader(writer.Buffer) { AnchoredTimeShift = shift });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_RoundTripsExactly_WithZeroShift()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(value);
|
||||||
|
|
||||||
|
Assert.Equal(value, read(TimeSpan.Zero).ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_AppliesShiftOnRead()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc);
|
||||||
|
var shift = TimeSpan.FromHours(3);
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(value);
|
||||||
|
|
||||||
|
Assert.Equal(value + shift, read(shift).ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_SentinelsPassThroughUnshifted()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(DateTime.MinValue);
|
||||||
|
writer.WriteAnchoredTime(DateTime.MaxValue);
|
||||||
|
|
||||||
|
var reader = read(TimeSpan.FromDays(2));
|
||||||
|
Assert.Equal(DateTime.MinValue, reader.ReadAnchoredTime());
|
||||||
|
Assert.Equal(DateTime.MaxValue, reader.ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_SaturatesInsteadOfOverflowing()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(DateTime.MaxValue - TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
Assert.Equal(DateTime.MaxValue, read(TimeSpan.FromDays(1)).ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_NormalizesLocalKindOnWrite()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
var local = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Local);
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(local);
|
||||||
|
|
||||||
|
Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class AnchoredEntity : ISerializable
|
||||||
|
{
|
||||||
|
public AnchoredEntity(Serial serial) => Serial = serial;
|
||||||
|
|
||||||
|
public Serial Serial { get; }
|
||||||
|
public DateTime Created { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool Deleted => false;
|
||||||
|
|
||||||
|
public DateTime LastRested { get; set; }
|
||||||
|
|
||||||
|
public void Delete()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Serialize(IGenericWriter writer) => writer.WriteAnchoredTime(LastRested);
|
||||||
|
|
||||||
|
public void Deserialize(IGenericReader reader) => LastRested = reader.ReadAnchoredTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class AnchoredTimePersistenceTests
|
||||||
|
{
|
||||||
|
private class AnchoredPersistence : GenericEntityPersistence<AnchoredEntity>
|
||||||
|
{
|
||||||
|
public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The idx v5 header carries the save-start anchor; loading re-bases anchored timestamps
|
||||||
|
/// by the elapsed time since the save started, so downtime does not age them.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad()
|
||||||
|
{
|
||||||
|
var previousAssemblies = AssemblyHandler.Assemblies;
|
||||||
|
AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly];
|
||||||
|
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
var workers = new SerializationThreadWorker[2];
|
||||||
|
for (var i = 0; i < workers.Length; i++)
|
||||||
|
{
|
||||||
|
workers[i] = new SerializationThreadWorker(i, source);
|
||||||
|
workers[i].AllocateHeap();
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousWorkers = World._threadWorkers;
|
||||||
|
World._threadWorkers = workers;
|
||||||
|
|
||||||
|
var previousSaveStart = World.SaveStartTime;
|
||||||
|
|
||||||
|
var persistence = new AnchoredPersistence(2100);
|
||||||
|
AnchoredPersistence loaded = null;
|
||||||
|
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lastRested = Core.Now - TimeSpan.FromMinutes(10);
|
||||||
|
var serial = (Serial)1u;
|
||||||
|
persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested };
|
||||||
|
persistence.RegisterType(typeof(AnchoredEntity));
|
||||||
|
|
||||||
|
// Pretend the save started two hours ago, as if the server had been down since.
|
||||||
|
var downtime = TimeSpan.FromHours(2);
|
||||||
|
World.SaveStartTime = Core.Now - downtime;
|
||||||
|
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
source.SetOwner(persistence);
|
||||||
|
Assert.True(persistence.TrySnapshotEntries(out var slotCount));
|
||||||
|
source.PushSlotRanges(persistence, slotCount);
|
||||||
|
|
||||||
|
source.Flush();
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Sleep();
|
||||||
|
}
|
||||||
|
|
||||||
|
persistence.WriteSnapshot(dir);
|
||||||
|
persistence.PostWorldSave();
|
||||||
|
|
||||||
|
loaded = new AnchoredPersistence(2101);
|
||||||
|
loaded.DeserializeIndexes(dir, null);
|
||||||
|
loaded.Deserialize(dir, null);
|
||||||
|
|
||||||
|
var entity = loaded.EntitiesBySerial[serial];
|
||||||
|
var expected = lastRested + downtime;
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
(entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30),
|
||||||
|
$"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
World.SaveStartTime = previousSaveStart;
|
||||||
|
persistence.Unregister();
|
||||||
|
loaded?.Unregister();
|
||||||
|
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
World._threadWorkers = previousWorkers;
|
||||||
|
AssemblyHandler.Assemblies = previousAssemblies;
|
||||||
|
Directory.Delete(dir, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long Position => _reader.Position;
|
public long Position => _reader.Position;
|
||||||
|
|
||||||
|
public TimeSpan AnchoredTimeShift
|
||||||
|
{
|
||||||
|
get => _reader.AnchoredTimeShift;
|
||||||
|
set => _reader.AnchoredTimeShift = value;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();
|
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ public class BufferReader : IGenericReader
|
||||||
public long Position => _position;
|
public long Position => _position;
|
||||||
public long BufferSize => _buffer.Length;
|
public long BufferSize => _buffer.Length;
|
||||||
|
|
||||||
|
public TimeSpan AnchoredTimeShift { get; set; }
|
||||||
|
|
||||||
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
|
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
|
||||||
{
|
{
|
||||||
_buffer = buffer;
|
_buffer = buffer;
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,21 @@ public class BufferWriter : IGenericWriter
|
||||||
Write(value.Ticks - DateTime.UtcNow.Ticks);
|
Write(value.Ticks - DateTime.UtcNow.Ticks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the absolute value; <see cref="IGenericReader.ReadAnchoredTime" /> re-bases it
|
||||||
|
/// by the elapsed time since the save started, so downtime does not age it and an
|
||||||
|
/// unchanged value serializes to identical bytes.
|
||||||
|
/// </summary>
|
||||||
|
public void WriteAnchoredTime(DateTime value)
|
||||||
|
{
|
||||||
|
if (value.Kind == DateTimeKind.Local)
|
||||||
|
{
|
||||||
|
value = value.ToUniversalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
Write(value.Ticks);
|
||||||
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(IPAddress value)
|
public void Write(IPAddress value)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
using var binFs = new FileStream(
|
using var binFs = new FileStream(
|
||||||
Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024
|
Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024
|
||||||
);
|
);
|
||||||
// v4 records are fixed-width 26 bytes; the header carries the type table
|
// v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor
|
||||||
// (name lengths vary — 64 bytes per entry is a staging hint, not a contract).
|
// and the type table (name lengths vary — 64 bytes per entry is a staging hint, not
|
||||||
var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
// a contract).
|
||||||
|
var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
||||||
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
|
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
|
||||||
|
|
||||||
var binPosition = 0L;
|
var binPosition = 0L;
|
||||||
|
|
@ -142,7 +143,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
binPosition += _selfLength;
|
binPosition += _selfLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
idx.Write(4); // Version
|
idx.Write(5); // Version
|
||||||
|
|
||||||
|
// One anchor for the whole save: the world is frozen from the moment it is stamped.
|
||||||
|
idx.Write(World.SaveStartTime.Ticks);
|
||||||
|
|
||||||
// The type table is fully known at freeze (AddEntity diverts to the pending
|
// The type table is fully known at freeze (AddEntity diverts to the pending
|
||||||
// queues while saving) and is written before the records so the loader can
|
// queues while saving) and is written before the records so the loader can
|
||||||
|
|
@ -494,6 +498,14 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
var version = dataReader.ReadInt();
|
var version = dataReader.ReadInt();
|
||||||
|
|
||||||
|
if (version >= 5)
|
||||||
|
{
|
||||||
|
// Re-base anchored timestamps by the elapsed time since the save started.
|
||||||
|
var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc);
|
||||||
|
var shift = Core.Now - anchor;
|
||||||
|
_anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
if (version >= 4)
|
if (version >= 4)
|
||||||
{
|
{
|
||||||
DeserializeIndexesV4(dataReader, entities);
|
DeserializeIndexesV4(dataReader, entities);
|
||||||
|
|
@ -660,6 +672,9 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
private static List<T> _toDelete;
|
private static List<T> _toDelete;
|
||||||
|
|
||||||
|
// From the loaded idx (v5+); zero when the save predates the anchor.
|
||||||
|
private TimeSpan _anchoredTimeShift;
|
||||||
|
|
||||||
private unsafe void InternalDeserialize(string filePath, int index, Dictionary<ulong, string> typesDb)
|
private unsafe void InternalDeserialize(string filePath, int index, Dictionary<ulong, string> typesDb)
|
||||||
{
|
{
|
||||||
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
|
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
|
||||||
|
|
@ -667,7 +682,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
byte* ptr = null;
|
byte* ptr = null;
|
||||||
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
||||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
|
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
|
||||||
|
{
|
||||||
|
AnchoredTimeShift = _anchoredTimeShift
|
||||||
|
};
|
||||||
|
|
||||||
Deserialize(dataReader);
|
Deserialize(dataReader);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,37 @@ public interface IGenericReader
|
||||||
var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc)
|
var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Elapsed time between the loaded save starting and this load, applied by
|
||||||
|
/// <see cref="ReadAnchoredTime" />. Zero when the source carries no anchor.
|
||||||
|
/// </summary>
|
||||||
|
TimeSpan AnchoredTimeShift => TimeSpan.Zero;
|
||||||
|
|
||||||
|
DateTime ReadAnchoredTime()
|
||||||
|
{
|
||||||
|
var value = ReadDateTime();
|
||||||
|
|
||||||
|
if (value == DateTime.MinValue || value == DateTime.MaxValue)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shift = AnchoredTimeShift;
|
||||||
|
if (shift == TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticks = value.Ticks + shift.Ticks;
|
||||||
|
|
||||||
|
if (ticks >= DateTime.MaxValue.Ticks)
|
||||||
|
{
|
||||||
|
return DateTime.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]);
|
decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]);
|
||||||
int ReadEncodedInt()
|
int ReadEncodedInt()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ public interface IGenericWriter
|
||||||
void WriteEncodedInt(int value);
|
void WriteEncodedInt(int value);
|
||||||
void Write(DateTime value);
|
void Write(DateTime value);
|
||||||
void WriteDeltaTime(DateTime value);
|
void WriteDeltaTime(DateTime value);
|
||||||
|
void WriteAnchoredTime(DateTime value);
|
||||||
void Write(IPAddress value);
|
void Write(IPAddress value);
|
||||||
void Write(TimeSpan value);
|
void Write(TimeSpan value);
|
||||||
void Write(Point3D value);
|
void Write(Point3D value);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long Position { get; private set; }
|
public long Position { get; private set; }
|
||||||
|
|
||||||
|
public TimeSpan AnchoredTimeShift { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read bits of data raw from a serialized file using Little-endian.
|
/// Read bits of data raw from a serialized file using Little-endian.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@ public static class World
|
||||||
public static string SavePath { get; private set; }
|
public static string SavePath { get; private set; }
|
||||||
public static WorldState WorldState { get; private set; }
|
public static WorldState WorldState { get; private set; }
|
||||||
public static bool Saving => WorldState == WorldState.Saving;
|
public static bool Saving => WorldState == WorldState.Saving;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UTC time the current or most recent world save started. Written into save indexes so
|
||||||
|
/// anchored timestamps can be re-based by the downtime at load.
|
||||||
|
/// </summary>
|
||||||
|
public static DateTime SaveStartTime { get; internal set; }
|
||||||
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
|
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
|
||||||
public static bool Loading => WorldState == WorldState.Loading;
|
public static bool Loading => WorldState == WorldState.Loading;
|
||||||
|
|
||||||
|
|
@ -287,6 +293,10 @@ public static class World
|
||||||
|
|
||||||
WorldState = WorldState.Saving;
|
WorldState = WorldState.Saving;
|
||||||
|
|
||||||
|
// The world is frozen from here: one anchor for the whole save. Written into save
|
||||||
|
// indexes so anchored timestamps can be re-based by the downtime at load.
|
||||||
|
SaveStartTime = Core.Now;
|
||||||
|
|
||||||
Broadcast(0x35, true, "The world is saving, please wait.");
|
Broadcast(0x35, true, "The world is saving, please wait.");
|
||||||
|
|
||||||
logger.Information("Saving world");
|
logger.Information("Saving world");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue