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>
|
||||
public long Position => _reader.Position;
|
||||
|
||||
public TimeSpan AnchoredTimeShift
|
||||
{
|
||||
get => _reader.AnchoredTimeShift;
|
||||
set => _reader.AnchoredTimeShift = value;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ public class BufferReader : IGenericReader
|
|||
public long Position => _position;
|
||||
public long BufferSize => _buffer.Length;
|
||||
|
||||
public TimeSpan AnchoredTimeShift { get; set; }
|
||||
|
||||
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
|
||||
{
|
||||
_buffer = buffer;
|
||||
|
|
|
|||
|
|
@ -407,6 +407,21 @@ public class BufferWriter : IGenericWriter
|
|||
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)]
|
||||
public void Write(IPAddress value)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
using var binFs = new FileStream(
|
||||
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
|
||||
// (name lengths vary — 64 bytes per entry is a staging hint, not a contract).
|
||||
var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
||||
// v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor
|
||||
// and the type table (name lengths vary — 64 bytes per entry is a staging hint, not
|
||||
// a contract).
|
||||
var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
||||
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
|
||||
|
||||
var binPosition = 0L;
|
||||
|
|
@ -142,7 +143,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
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
|
||||
// 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();
|
||||
|
||||
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)
|
||||
{
|
||||
DeserializeIndexesV4(dataReader, entities);
|
||||
|
|
@ -660,6 +672,9 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
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)
|
||||
{
|
||||
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
|
||||
|
|
@ -667,7 +682,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
byte* ptr = null;
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,37 @@ public interface IGenericReader
|
|||
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()]);
|
||||
int ReadEncodedInt()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public interface IGenericWriter
|
|||
void WriteEncodedInt(int value);
|
||||
void Write(DateTime value);
|
||||
void WriteDeltaTime(DateTime value);
|
||||
void WriteAnchoredTime(DateTime value);
|
||||
void Write(IPAddress value);
|
||||
void Write(TimeSpan value);
|
||||
void Write(Point3D value);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader
|
|||
/// </summary>
|
||||
public long Position { get; private set; }
|
||||
|
||||
public TimeSpan AnchoredTimeShift { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Read bits of data raw from a serialized file using Little-endian.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ public static class World
|
|||
public static string SavePath { get; private set; }
|
||||
public static WorldState WorldState { get; private set; }
|
||||
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 Loading => WorldState == WorldState.Loading;
|
||||
|
||||
|
|
@ -287,6 +293,10 @@ public static class World
|
|||
|
||||
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.");
|
||||
|
||||
logger.Information("Saving world");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue