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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue