fix: Cleans up entity persistence. Generalizes Mobiles/Items/Guilds (#1528)

### Summary
- [X] Fixed a bug where entity persistence was serialized out of order, causing world corruption.
- [X] Fixed LastSerialized not being utilized properly and dangling references still becoming an issue.
- [X] Added a new `GenericEntityPersistence<T>` type to encapsulate `ISerializable` serialization.
- [X] Removing the custom logic and moved Items, Mobiles, Guilds, and Accounts  to GenericEntityPersistence.
- [X] Changed serialization to use the singleton pattern to reduce calling methods from stored variables.
This commit is contained in:
Kamron Batman 2023-10-01 22:10:47 -07:00 committed by GitHub
parent e0225c6e59
commit f2de2fbb77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 721 additions and 970 deletions

View file

@ -51,7 +51,7 @@ public static class AdhocPersistence
/**
* Serializes to a memory buffer synchronously, then flushes to the path asynchronously.
* See WriteSnapshot for more info about how the snapshot.
* See WriteSnapshot for more info about how to snapshot.
*/
public static void SerializeAndSnapshot(string filePath, Action<IGenericWriter> serializer, ConcurrentQueue<Type> types = null)
{

View file

@ -0,0 +1,311 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2023 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: GenericEntityPersistence.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Server.Logging;
namespace Server;
public interface IGenericEntityPersistence
{
public void DeserializeIndexes(string savePath, Dictionary<ulong, string> typesDb);
}
public class GenericEntityPersistence<T> : Persistence, IGenericEntityPersistence where T : class, ISerializable
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenericEntityPersistence<T>));
private static List<EntitySpan<T>> _entities;
private string _name;
private Serial _lastEntitySerial;
private readonly Dictionary<Serial, T> _pendingAdd = new();
private readonly Dictionary<Serial, T> _pendingDelete = new();
private uint _minSerial;
private uint _maxSerial;
public Dictionary<Serial, T> EntitiesBySerial { get; private set; } = new();
public GenericEntityPersistence(string name, int priority, uint minSerial, uint maxSerial) : base(priority)
{
_name = name;
_minSerial = minSerial;
_maxSerial = maxSerial;
typeof(T).RegisterFindEntity(Find);
}
public override void Serialize()
{
// TODO: Hand off to a scheduler instead
Parallel.ForEach(EntitiesBySerial.Values, SerializeEntity);
}
protected virtual void SerializeEntity(T entity)
{
entity.Serialize(World.SerializedTypes);
}
public override void WriteSnapshot(string basePath)
{
IIndexInfo<Serial> indexInfo = new EntityTypeIndex(_name);
EntityPersistence.WriteEntities(indexInfo, EntitiesBySerial, basePath,World.SerializedTypes, out _);
}
public virtual void DeserializeIndexes(string savePath, Dictionary<ulong, string> typesDb)
{
IIndexInfo<Serial> indexInfo = new EntityTypeIndex(_name);
EntitiesBySerial = EntityPersistence.LoadIndex(savePath, indexInfo, typesDb, out _entities);
if (EntitiesBySerial.Count > 0)
{
_lastEntitySerial = EntitiesBySerial.Keys.Max();
}
}
public override void Deserialize(string savePath, Dictionary<ulong, string> typesDb)
{
IIndexInfo<Serial> indexInfo = new EntityTypeIndex(_name);
EntityPersistence.LoadData(savePath, indexInfo, typesDb, _entities);
_entities = null;
}
public override void PostSerialize()
{
ProcessSafetyQueues();
}
public override void PostDeserialize()
{
ProcessSafetyQueues();
}
public Serial NewEntity
{
get
{
#if THREADGUARD
if (Thread.CurrentThread != Core.Thread)
{
logger.Error(
"Attempted to get a new entity serial from the wrong thread!\n{StackTrace}",
new StackTrace()
);
}
#endif
var last = _lastEntitySerial;
var max = (Serial)_maxSerial;
for (uint i = 0; i < _maxSerial; i++)
{
last++;
if (last > max)
{
last = (Serial)_minSerial;
}
if (FindEntity<T>(last) == null)
{
return _lastEntitySerial = last;
}
}
OutOfMemory($"No serials left to allocate for {_name}");
return Serial.MinusOne;
}
}
public void AddEntity(T entity)
{
var worldState = World.WorldState;
switch (worldState)
{
default: // Not Running
{
throw new Exception($"Added {entity.GetType().Name} before world load.");
}
case WorldState.Saving:
{
AppendSafetyLog("add", entity);
goto case WorldState.WritingSave;
}
case WorldState.Loading:
case WorldState.WritingSave:
{
if (_pendingDelete.Remove(entity.Serial))
{
logger.Warning("Deleted then added {Entity} during {WorldState} state.", entity.GetType().Name, worldState.ToString());
}
_pendingAdd[entity.Serial] = entity;
break;
}
case WorldState.Running:
{
ref var entityEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(EntitiesBySerial, entity.Serial, out bool exists);
if (exists)
{
if (entityEntry == entity)
{
logger.Error(
$"Attempted to add '{{Entity}}' ({{Serial}}) to World.Items but it already exists in the collection.{Environment.NewLine}{{StackTrace}}",
entity.GetType().FullName,
entity.Serial,
new StackTrace()
);
}
else
{
logger.Error(
$"Attempted to add '{{Entity}}' ({{Serial}}) to World.Items but found '{{ExistingEntity}}' ({{ExistingSerial}}).{Environment.NewLine}{{StackTrace}}",
entity.GetType().FullName,
entity.Serial,
entityEntry.GetType().FullName,
entityEntry.Serial,
new StackTrace()
);
}
}
else
{
entityEntry = entity;
}
break;
}
}
}
public void RemoveEntity(T entity)
{
var worldState = World.WorldState;
switch (worldState)
{
default: // Not Running
{
throw new Exception($"Removed {entity.GetType().Name} before world load.");
}
case WorldState.Saving:
{
AppendSafetyLog("delete", entity);
goto case WorldState.WritingSave;
}
case WorldState.Loading:
case WorldState.WritingSave:
{
_pendingAdd.Remove(entity.Serial);
_pendingDelete[entity.Serial] = entity;
break;
}
case WorldState.Running:
{
EntitiesBySerial.Remove(entity.Serial);
break;
}
}
}
private void ProcessSafetyQueues()
{
foreach (var entity in _pendingAdd.Values)
{
AddEntity(entity);
}
_pendingAdd.Clear();
foreach (var entity in _pendingDelete.Values)
{
if (_pendingAdd.ContainsKey(entity.Serial))
{
logger.Warning("Entity {Entity} was both pending deletion and addition after save", entity);
}
RemoveEntity(entity);
}
_pendingDelete.Clear();
}
private void AppendSafetyLog(string action, ISerializable entity)
{
var message =
$"Warning: Attempted to {{Action}} {{Entity}} during world save.{Environment.NewLine}This action could cause inconsistent state.{Environment.NewLine}It is strongly advised that the offending scripts be corrected.";
logger.Information(message, action, entity);
try
{
using var op = new StreamWriter("world-save-errors.log", true);
op.WriteLine("{0}\t{1}", DateTime.UtcNow, message);
op.WriteLine(new StackTrace(2).ToString());
op.WriteLine();
}
catch
{
// ignored
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T Find(Serial serial) => FindEntity<T>(serial, false, false);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T Find(Serial serial, bool returnDeleted) => FindEntity<T>(serial, returnDeleted, false);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T Find(Serial serial, bool returnDeleted, bool returnPending) => FindEntity<T>(serial, returnDeleted, returnPending);
public R FindEntity<R>(Serial serial) where R : class, T => FindEntity<R>(serial, false, false);
public R FindEntity<R>(Serial serial, bool returnDeleted, bool returnPending) where R : class, T
{
switch (World.WorldState)
{
default: return null;
case WorldState.Loading:
case WorldState.Saving:
case WorldState.WritingSave:
{
if (returnDeleted && returnPending && _pendingDelete.TryGetValue(serial, out var entity))
{
return entity as R;
}
if (returnPending && _pendingAdd.TryGetValue(serial, out entity) ||
EntitiesBySerial.TryGetValue(serial, out entity))
{
return entity as R;
}
return null;
}
case WorldState.Running:
{
return EntitiesBySerial.TryGetValue(serial, out var entity) ? entity as R : null;
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void OutOfMemory(string message) => throw new OutOfMemoryException(message);
}

View file

@ -1,209 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Server.Logging;
namespace Server;
public class GenericEntitySerialization<T> where T : class, ISerializable
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenericEntitySerialization<T>));
private static string _systemName;
private static Serial _lastEntitySerial;
private static readonly Dictionary<Serial, T> _pendingAdd = new();
private static readonly Dictionary<Serial, T> _pendingDelete = new();
private static Dictionary<Serial, T> _entitiesBySerial = new();
public static void Configure(string systemName)
{
_systemName = systemName;
typeof(T).RegisterFindEntity(Find);
Persistence.Register(_systemName, Serialize, WriteSnapshot, Deserialize);
}
internal static void Serialize()
{
EntityPersistence.SaveEntities(
_entitiesBySerial.Values,
entity => entity.Serialize(World.SerializedTypes)
);
}
internal static void WriteSnapshot(string basePath)
{
IIndexInfo<Serial> indexInfo = new EntityTypeIndex(_systemName);
EntityPersistence.WriteEntities(indexInfo, _entitiesBySerial, basePath,World.SerializedTypes, out _);
}
internal static void Deserialize(string path, Dictionary<ulong, string> typesDb)
{
IIndexInfo<Serial> indexInfo = new EntityTypeIndex(_systemName);
_entitiesBySerial = EntityPersistence.LoadIndex(path, indexInfo, typesDb, out List<EntitySpan<T>> entities);
if (_entitiesBySerial.Count > 0)
{
_lastEntitySerial = _entitiesBySerial.Keys.Max();
}
EntityPersistence.LoadData(path, indexInfo, typesDb, entities);
}
public static Serial NewEntity
{
get
{
#if THREADGUARD
if (Thread.CurrentThread != Core.Thread)
{
logger.Error(
"Attempted to get a new entity serial from the wrong thread!\n{StackTrace}",
new StackTrace()
);
}
#endif
var last = _lastEntitySerial;
for (uint i = 0; i < uint.MaxValue; i++)
{
last++;
if (FindEntity<T>(last) == null)
{
return _lastEntitySerial = last;
}
}
OutOfMemory("No serials left to allocate for BOBEntries");
return Serial.MinusOne;
}
}
public static void AddEntity(T entity)
{
var worldState = World.WorldState;
switch (worldState)
{
default: // Not Running
{
throw new Exception($"Added {entity.GetType().Name} before world load.");
}
case WorldState.Saving:
case WorldState.Loading:
case WorldState.WritingSave:
{
if (_pendingDelete.Remove(entity.Serial))
{
logger.Warning("Deleted then added {Entity} during {WorldState} state.", entity.GetType().Name, worldState.ToString());
}
_pendingAdd[entity.Serial] = entity;
break;
}
case WorldState.Running:
{
ref var entityEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_entitiesBySerial, entity.Serial, out bool exists);
if (exists)
{
if (entityEntry == entity)
{
logger.Error(
$"Attempted to add '{{Entity}}' ({{Serial}}) to World.Items but it already exists in the collection.{Environment.NewLine}{{StackTrace}}",
entity.GetType().FullName,
entity.Serial,
new StackTrace()
);
}
else
{
logger.Error(
$"Attempted to add '{{Entity}}' ({{Serial}}) to World.Items but found '{{ExistingEntity}}' ({{ExistingSerial}}).{Environment.NewLine}{{StackTrace}}",
entity.GetType().FullName,
entity.Serial,
entityEntry.GetType().FullName,
entityEntry.Serial,
new StackTrace()
);
}
}
else
{
entityEntry = entity;
}
break;
}
}
}
public static void RemoveEntity(T entity)
{
var worldState = World.WorldState;
switch (worldState)
{
default: // Not Running
{
throw new Exception($"Removed {entity.GetType().Name} before world load.");
}
case WorldState.Saving:
case WorldState.Loading:
case WorldState.WritingSave:
{
_pendingAdd.Remove(entity.Serial);
_pendingDelete[entity.Serial] = entity;
break;
}
case WorldState.Running:
{
_entitiesBySerial.Remove(entity.Serial);
break;
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T Find(Serial serial) => FindEntity<T>(serial, false, false);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T Find(Serial serial, bool returnDeleted) => FindEntity<T>(serial, returnDeleted, false);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T Find(Serial serial, bool returnDeleted, bool returnPending) => FindEntity<T>(serial, returnDeleted, returnPending);
public static R FindEntity<R>(Serial serial) where R : class, T => FindEntity<R>(serial, false, false);
public static R FindEntity<R>(Serial serial, bool returnDeleted, bool returnPending) where R : class, T
{
switch (World.WorldState)
{
default: return null;
case WorldState.Loading:
case WorldState.Saving:
case WorldState.WritingSave:
{
if (returnDeleted && returnPending && _pendingDelete.TryGetValue(serial, out var entity))
{
return entity as R;
}
if (returnPending && _pendingAdd.TryGetValue(serial, out entity) ||
_entitiesBySerial.TryGetValue(serial, out entity))
{
return entity as R;
}
return null;
}
case WorldState.Running:
{
return _entitiesBySerial.TryGetValue(serial, out var entity) ? entity as R : null;
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void OutOfMemory(string message) => throw new OutOfMemoryException(message);
}

View file

@ -19,35 +19,33 @@ using System.IO;
namespace Server;
public static class GenericPersistence
public abstract class GenericPersistence : Persistence
{
public static void Register(
string name,
Action<IGenericWriter> serializer,
Action<IGenericReader> deserializer,
int priority = Persistence.DefaultPriority
)
private BufferWriter _saveBuffer;
public string Name { get; }
public GenericPersistence(string name, int priority) : base(priority) => Name = name;
public override void Serialize()
{
BufferWriter saveBuffer = null;
_saveBuffer ??= new BufferWriter(true, World.SerializedTypes);
_saveBuffer.Seek(0, SeekOrigin.Begin);
void Serialize()
{
saveBuffer ??= new BufferWriter(true, World.SerializedTypes);
saveBuffer.Seek(0, SeekOrigin.Begin);
serializer(saveBuffer);
}
void WriteSnapshot(string savePath)
{
string binPath = Path.Combine(savePath, name, $"{name}.bin");
var buffer = saveBuffer!.Buffer.AsSpan(0, (int)saveBuffer.Position);
AdhocPersistence.WriteSnapshot(new FileInfo(binPath), buffer);
}
void Deserialize(string savePath, Dictionary<ulong, string> typesDb) =>
AdhocPersistence.Deserialize(Path.Combine(savePath, name, $"{name}.bin"), deserializer);
Persistence.Register(name, Serialize, WriteSnapshot, Deserialize, priority);
Serialize(_saveBuffer);
}
public abstract void Serialize(IGenericWriter writer);
public override void WriteSnapshot(string basePath)
{
string binPath = Path.Combine(basePath, Name, $"{Name}.bin");
var buffer = _saveBuffer!.Buffer.AsSpan(0, (int)_saveBuffer.Position);
AdhocPersistence.WriteSnapshot(new FileInfo(binPath), buffer);
}
public override void Deserialize(string savePath, Dictionary<ulong, string> typesDb) =>
AdhocPersistence.Deserialize(Path.Combine(savePath, Name, $"{Name}.bin"), Deserialize);
public abstract void Deserialize(IGenericReader reader);
}

View file

@ -21,38 +21,31 @@ using System.Threading.Tasks;
namespace Server;
public static class Persistence
public abstract class Persistence
{
public const int DefaultPriority = 100;
private static readonly SortedSet<Persistence> _registry = new(new PersistenceComparer());
private static readonly SortedSet<RegistryEntry> _registry = new(new RegistryEntryComparer());
public int Priority { get; }
public static void Register(
string name,
Action serializer,
Action<string> snapshotWriter,
Action<string, Dictionary<ulong, string>> deserializer,
int priority = DefaultPriority
)
public Persistence(int priority = 100)
{
_registry.Add(
new RegistryEntry
{
Name = name,
Priority = priority,
Serialize = serializer,
WriteSnapshot = snapshotWriter,
Deserialize = deserializer
}
);
Priority = priority;
_registry.Add(this);
}
public static void Unregister(string name) => _registry.RemoveWhere(entry => entry.Name == name);
public bool Register() => _registry.Add(this);
public void Unregister() => _registry.Remove(this);
public static void Load(string path)
{
var typesDb = LoadTypes(path);
foreach (var entry in _registry)
{
(entry as IGenericEntityPersistence)?.DeserializeIndexes(path, typesDb);
}
// This should probably not be parallel since Mobiles must be loaded before Items
foreach (var entry in _registry)
{
@ -86,9 +79,26 @@ public static class Persistence
return db;
}
public static void Serialize()
internal static void SerializeAll()
{
Parallel.ForEach(_registry, entry => entry.Serialize());
// TODO: Hand off to a scheduler
Parallel.ForEach(_registry, p => p.Serialize());
}
internal static void PostSerializeAll()
{
foreach (var p in _registry)
{
p.PostSerialize();
}
}
internal static void PostDeserializeAll()
{
foreach (var p in _registry)
{
p.PostDeserialize();
}
}
public static void WriteSnapshot(string path, ConcurrentQueue<Type> types)
@ -126,20 +136,24 @@ public static class Persistence
}
}
public record RegistryEntry
{
public string Name { get; init; }
public int Priority { get; init; }
// Serializes to memory buffers and run in parallel
public abstract void Serialize();
// Serializes to memory buffers and run in parallel
public Action Serialize { get; init; }
public Action<string> WriteSnapshot { get; init; }
public Action<string, Dictionary<ulong, string>> Deserialize { get; init; }
public abstract void WriteSnapshot(string savePath);
public abstract void Deserialize(string savePath, Dictionary<ulong, string> typesDb);
public virtual void PostSerialize()
{
}
internal class RegistryEntryComparer : IComparer<RegistryEntry>
public virtual void PostDeserialize()
{
public int Compare(RegistryEntry x, RegistryEntry y)
}
internal class PersistenceComparer : IComparer<Persistence>
{
public int Compare(Persistence x, Persistence y)
{
if (x == y)
{
@ -160,7 +174,7 @@ public static class Persistence
var cmp = x.Priority.CompareTo(y.Priority);
// Then alphabetically. We won't allow the same entry (by name) twice in the SortedSet
return cmp != 0 ? cmp : x.Name?.CompareOrdinal(y.Name) ?? -1;
return cmp != 0 ? cmp : x.GetHashCode().CompareTo(y.GetHashCode());
}
}