diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc0aa5..c23dabb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed - Storage files are now written on a shared background thread. Changes are still serialized on the calling thread, but `Set` and auto-save no longer wait for the disk. `Save()` and `Dispose()` still block until the data has reached the disk. +- Auto-save no longer serializes the whole storage on every change. While the previous snapshot is still on its way to the disk, a change only marks the storage as changed; the storage is serialized once more when the writer becomes free, on the thread that built it. A burst of 200 `Set` calls now costs one serialization instead of 200. A storage built on a thread without a `SynchronizationContext` keeps serializing on every change, as before. ### Added - `SaveOnBackgroundThread(bool)` on the builder. Pass `false` to write the file before every change returns, as before. diff --git a/src/Runtime/BinaryStorage.cs b/src/Runtime/BinaryStorage.cs index 9094f6c..593a5e8 100644 --- a/src/Runtime/BinaryStorage.cs +++ b/src/Runtime/BinaryStorage.cs @@ -13,8 +13,10 @@ public partial class BinaryStorage : IDisposable, IBinaryStorage private readonly string _storageFilePath; private readonly StoragePersistence _persistence; private readonly IReadOnlyList _supportedTypes; + private readonly Dictionary _sectionIndexByType; private readonly Dictionary _data = new(); private readonly Dictionary _collections = new(); + private readonly Action _decreaseCounter; private int _changeScopeCounter; private bool _hasUnsavedChanges; @@ -37,6 +39,8 @@ public bool SaveJsonCopyForDebug /// Gets a value indicating whether the storage has been disposed. public bool IsDisposed { get; private set; } + internal int SerializeCount => _persistence.SerializeCount; + /// Initializes a new instance of the class. /// The file path for storing data. /// The list of supported types for storage. @@ -45,7 +49,13 @@ internal BinaryStorage(string storageFilePath, IReadOnlyList supp { _storageFilePath = storageFilePath; _supportedTypes = supportedTypes; - _persistence = new StoragePersistence(storageFilePath, supportedTypes, saveOnBackgroundThread); + _sectionIndexByType = new Dictionary(supportedTypes.Count); + for (var i = 0; i < supportedTypes.Count; i++) + { + _sectionIndexByType[supportedTypes[i].Type] = i; + } + _decreaseCounter = DecreaseCounter; + _persistence = new StoragePersistence(storageFilePath, supportedTypes, saveOnBackgroundThread, SaveDeferredChanges); } #region Events @@ -104,7 +114,7 @@ public virtual bool SetRaw(string key, object value, TypeMismatchBehaviour? over } var valueType = value.GetType(); - if (valueType.IsCollection()) + if (CollectionTypeCache.IsCollection(valueType)) { throw new IncorrectUsageOfCollectionException(nameof(SetRaw), valueType); } @@ -128,23 +138,17 @@ public virtual bool SetRaw(string key, object value, TypeMismatchBehaviour? over return true; } - var mismatchBehaviour = overrideTypeMismatchBehaviour ?? TypeMismatchBehaviour; - switch (mismatchBehaviour) - { - case TypeMismatchBehaviour.OverrideValueAndType: - using (new ChangeScope(this)) - { - RemoveRecord(key); - AddRawRecord(key, value, valueType); - } - return true; - case TypeMismatchBehaviour.ThrowException: - throw new UnexpectedTypeException(key, nameof(SetRaw), record.Type, valueType); - case TypeMismatchBehaviour.Ignore: - return false; - default: - throw new UnexpectedEnumException(typeof(TypeMismatchBehaviour), mismatchBehaviour); + if (!ShouldReplaceMismatchedRecord(key, record, valueType, overrideTypeMismatchBehaviour)) + { + return false; } + + using (new ChangeScope(this)) + { + RemoveRecord(key); + AddRawRecord(key, value, valueType); + } + return true; } /// Determines whether the specified key exists in the storage. @@ -236,23 +240,17 @@ public virtual bool Set(string key, T value, TypeMismatchBehaviour? overrideT return ChangeRecord(key, typedRecord, value); } - var mismatchBehaviour = overrideTypeMismatchBehaviour ?? TypeMismatchBehaviour; - switch (mismatchBehaviour) - { - case TypeMismatchBehaviour.OverrideValueAndType: - using (new ChangeScope(this)) - { - RemoveRecord(key); - AddRecord(key, value); - } - return true; - case TypeMismatchBehaviour.ThrowException: - throw new UnexpectedTypeException(key, nameof(Set), record.Type, typeof(T)); - case TypeMismatchBehaviour.Ignore: - return false; - default: - throw new UnexpectedEnumException(typeof(TypeMismatchBehaviour), mismatchBehaviour); + if (!ShouldReplaceMismatchedRecord(key, record, typeof(T), overrideTypeMismatchBehaviour)) + { + return false; } + + using (new ChangeScope(this)) + { + RemoveRecord(key); + AddRecord(key, value); + } + return true; } /// @@ -320,7 +318,7 @@ public IDisposable MultipleChangeScope() { ThrowIfDisposed(); _changeScopeCounter++; - return new DisposableScope(DecreaseCounter); + return new DisposableScope(_decreaseCounter); } #region Collections @@ -448,12 +446,7 @@ private Record AddRecord(string key, T value) var record = new Record(value, typeIndex); section.Count++; _data.Add(key, record); - var rc = record.AsReactiveCollection(); - if (rc != null) - { - _collections.Add(rc, key); - rc.OnChanged += ReactiveCollectionChanged; - } + TrackCollectionOf(record, key); MarkChanged(); OnKeyAdded?.Invoke(key); return record; @@ -466,15 +459,7 @@ private Record AddRecord(string key, T value) /// Thrown if the type is not registered. private void AddRawRecord(string key, object value, Type valueType) { - var typeIndex = -1; - for (var i = 0; i < _supportedTypes.Count; i++) - { - if (_supportedTypes[i].Type == valueType) - { - typeIndex = i; - break; - } - } + var typeIndex = IndexOfSection(valueType); if (typeIndex == -1) { throw new UnregisteredTypeException(valueType); @@ -518,13 +503,7 @@ private bool RemoveRecord(string key) { return false; } - var rc = value.AsReactiveCollection(); - if (rc != null) - { - rc.OnChanged -= ReactiveCollectionChanged; - rc.Dispose(); - _collections.Remove(rc); - } + UntrackCollectionOf(value); _supportedTypes[value.TypeIndex].Count--; _data.Remove(key); MarkChanged(); @@ -540,14 +519,7 @@ private void RemoveAllRecords() { foreach (var record in _data.Values) { - var rc = record.AsReactiveCollection(); - if (rc == null) - { - continue; - } - rc.OnChanged -= ReactiveCollectionChanged; - rc.Dispose(); - _collections.Remove(rc); + UntrackCollectionOf(record); } _data.Clear(); for (var i = 0; i < _supportedTypes.Count; i++) @@ -573,14 +545,50 @@ private Record GetRecord(string key) private int IndexOfSection() { - for (var i = 0; i < _supportedTypes.Count; i++) + return IndexOfSection(typeof(T)); + } + + private int IndexOfSection(Type type) + { + return _sectionIndexByType.TryGetValue(type, out var index) ? index : -1; + } + + /// Decides whether a record whose stored type differs from the type being written has to be replaced. + /// True if the record has to be replaced; false if the write has to be ignored. + /// Thrown if the mismatch behavior is to throw. + private bool ShouldReplaceMismatchedRecord(string key, Record record, Type valueType, TypeMismatchBehaviour? overrideTypeMismatchBehaviour, [CallerMemberName] string action = null) + { + var mismatchBehaviour = overrideTypeMismatchBehaviour ?? TypeMismatchBehaviour; + return mismatchBehaviour switch { - if (_supportedTypes[i] is TypedBinarySection) - { - return i; - } + TypeMismatchBehaviour.OverrideValueAndType => true, + TypeMismatchBehaviour.Ignore => false, + TypeMismatchBehaviour.ThrowException => throw new UnexpectedTypeException(key, action, record.Type, valueType), + _ => throw new UnexpectedEnumException(typeof(TypeMismatchBehaviour), mismatchBehaviour) + }; + } + + private void TrackCollectionOf(Record record, string key) + { + var collection = record.AsReactiveCollection(); + if (collection == null) + { + return; } - return -1; + _collections.Add(collection, key); + collection.OnChanged += ReactiveCollectionChanged; + } + + private void UntrackCollectionOf(Record record) + { + var collection = record.AsReactiveCollection(); + if (collection == null) + { + return; + } + collection.OnChanged -= ReactiveCollectionChanged; + collection.Dispose(); + _collections.Remove(collection); } /// @@ -594,14 +602,7 @@ private void DecreaseCounter() return; } _changeScopeCounter--; - if (IsDisposed) - { - return; - } - if (_changeScopeCounter == 0 && _hasUnsavedChanges && AutoSave) - { - SaveDataOnDisk(false); - } + SaveDeferredChanges(); } /// Reacts to a change in a reactive collection. @@ -689,14 +690,7 @@ private void Dispose(bool disposing) // Always dispose IReactiveCollection instances foreach (var record in _data.Values) { - var rc = record.AsReactiveCollection(); - if (rc == null) - { - continue; - } - rc.OnChanged -= ReactiveCollectionChanged; - rc.Dispose(); - _collections.Remove(rc); + UntrackCollectionOf(record); } OnKeyAdded = null; @@ -731,12 +725,7 @@ private void LoadDataFromDisk(KeyLoadFailedBehaviour keyLoadFailedBehaviour) _persistence.Load(_data, keyLoadFailedBehaviour); foreach (var pair in _data) { - var rc = pair.Value.AsReactiveCollection(); - if (rc != null) - { - _collections.Add(rc, pair.Key); - rc.OnChanged += ReactiveCollectionChanged; - } + TrackCollectionOf(pair.Value, pair.Key); } } @@ -747,10 +736,24 @@ private void LoadDataFromDisk(KeyLoadFailedBehaviour keyLoadFailedBehaviour) private void SaveDataOnDisk(bool waitForDisk) { ThrowIfDisposed(); + if (!waitForDisk && _persistence.TryDeferSave()) + { + _hasUnsavedChanges = true; + return; + } _persistence.Save(_data, waitForDisk); _hasUnsavedChanges = false; } + private void SaveDeferredChanges() + { + if (IsDisposed || !_hasUnsavedChanges) + { + return; + } + MarkChanged(); + } + #endregion } } diff --git a/src/Runtime/Collections/ReactiveCollection.cs b/src/Runtime/Collections/ReactiveCollection.cs new file mode 100644 index 0000000..5e4a0cc --- /dev/null +++ b/src/Runtime/Collections/ReactiveCollection.cs @@ -0,0 +1,38 @@ +using System; + +namespace Appegy.Storage +{ + internal abstract class ReactiveCollection : IReactiveCollection + { + public bool IsDisposed { get; private set; } + + public event Action OnChanged; + + protected abstract string ObjectName { get; } + + public abstract void Clear(); + + public void Dispose() + { + if (IsDisposed) + { + return; + } + Clear(); + IsDisposed = true; + } + + protected void SetDirty() + { + OnChanged?.Invoke(this); + } + + protected void ThrowIfDisposed() + { + if (IsDisposed) + { + throw new ObjectDisposedException(ObjectName); + } + } + } +} diff --git a/src/Runtime/Collections/ReactiveCollection.cs.meta b/src/Runtime/Collections/ReactiveCollection.cs.meta new file mode 100644 index 0000000..157ee7d --- /dev/null +++ b/src/Runtime/Collections/ReactiveCollection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4cdab2a8cf363ed409cecb771610aac0 \ No newline at end of file diff --git a/src/Runtime/Collections/ReactiveDictionary.cs b/src/Runtime/Collections/ReactiveDictionary.cs index 47c6b1c..ffc2133 100644 --- a/src/Runtime/Collections/ReactiveDictionary.cs +++ b/src/Runtime/Collections/ReactiveDictionary.cs @@ -1,42 +1,16 @@ -using System; -using System.Collections; +using System.Collections; using System.Collections.Generic; namespace Appegy.Storage { - internal class ReactiveDictionary : IReactiveCollection, IDictionary, IReadOnlyDictionary + internal class ReactiveDictionary : ReactiveCollection, IDictionary, IReadOnlyDictionary { private readonly Dictionary _dictionary = new(); - public bool IsDisposed { get; private set; } - - public event Action OnChanged; - - private void SetDirty() - { - OnChanged?.Invoke(this); - } - - private void ThrowIfDisposed() - { - if (IsDisposed) - { - throw new ObjectDisposedException(nameof(ReactiveDictionary)); - } - } + protected override string ObjectName => nameof(ReactiveDictionary); #region Mutable functionallity - public void Dispose() - { - if (IsDisposed) - { - return; - } - Clear(); - IsDisposed = true; - } - public TValue this[TKey key] { get @@ -59,7 +33,7 @@ public void Add(KeyValuePair item) SetDirty(); } - public void Clear() + public override void Clear() { ThrowIfDisposed(); _dictionary.Clear(); diff --git a/src/Runtime/Collections/ReactiveList.cs b/src/Runtime/Collections/ReactiveList.cs index f847366..788a2ef 100644 --- a/src/Runtime/Collections/ReactiveList.cs +++ b/src/Runtime/Collections/ReactiveList.cs @@ -1,42 +1,16 @@ -using System; using System.Collections; using System.Collections.Generic; namespace Appegy.Storage { - internal class ReactiveList : IReactiveCollection, IList, IReadOnlyList + internal class ReactiveList : ReactiveCollection, IList, IReadOnlyList { private readonly List _list = new(); - public bool IsDisposed { get; private set; } - - public event Action OnChanged; - - private void SetDirty() - { - OnChanged?.Invoke(this); - } - - private void ThrowIfDisposed() - { - if (IsDisposed) - { - throw new ObjectDisposedException(nameof(ReactiveList)); - } - } + protected override string ObjectName => nameof(ReactiveList); #region Mutable functionallity - public void Dispose() - { - if (IsDisposed) - { - return; - } - Clear(); - IsDisposed = true; - } - public T this[int index] { get @@ -59,7 +33,7 @@ public void Add(T item) SetDirty(); } - public void Clear() + public override void Clear() { ThrowIfDisposed(); if (_list.Count > 0) diff --git a/src/Runtime/Collections/ReactiveSet.cs b/src/Runtime/Collections/ReactiveSet.cs index 870bb8b..c19fda7 100644 --- a/src/Runtime/Collections/ReactiveSet.cs +++ b/src/Runtime/Collections/ReactiveSet.cs @@ -1,43 +1,17 @@ -using System; -using System.Collections; +using System.Collections; using System.Collections.Generic; using JetBrains.Annotations; namespace Appegy.Storage { - internal class ReactiveSet : IReactiveCollection, ISet, IReadOnlyCollection + internal class ReactiveSet : ReactiveCollection, ISet, IReadOnlyCollection { private readonly HashSet _set = new(); - public bool IsDisposed { get; private set; } - - public event Action OnChanged; - - private void SetDirty() - { - OnChanged?.Invoke(this); - } - - private void ThrowIfDisposed() - { - if (IsDisposed) - { - throw new ObjectDisposedException(nameof(ReactiveSet)); - } - } + protected override string ObjectName => nameof(ReactiveSet); #region Mutable functionallity - public void Dispose() - { - if (IsDisposed) - { - return; - } - Clear(); - IsDisposed = true; - } - public void ExceptWith(IEnumerable other) { ThrowIfDisposed(); @@ -95,7 +69,7 @@ public bool Add([CanBeNull] T item) return added; } - public void Clear() + public override void Clear() { ThrowIfDisposed(); var count = Count; diff --git a/src/Runtime/Internals/NestedBinaryStorage.cs b/src/Runtime/Internals/NestedBinaryStorage.cs index 6a3864b..a08d99b 100644 --- a/src/Runtime/Internals/NestedBinaryStorage.cs +++ b/src/Runtime/Internals/NestedBinaryStorage.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using UnityEngine.Pool; namespace Appegy.Storage @@ -11,6 +10,7 @@ internal class NestedBinaryStorage : IBinaryStorage private readonly IBinaryStorage _root; private readonly string _prefix; + private readonly Func _hasPrefix; private readonly Dictionary _prefixedKeys = new(); private int _keysAddedSinceCleanup; private int _keysAllowedBeforeCleanup = MinKeysAddedBetweenCleanups; @@ -19,16 +19,23 @@ public NestedBinaryStorage(IBinaryStorage root, string prefix) { _prefix = $"__{prefix}->"; _root = root; + _hasPrefix = HasPrefix; } public IReadOnlyCollection Keys { get { - return _root.Keys - .Where(k => k.StartsWith(_prefix, StringComparison.Ordinal)) - .Select(k => k.Substring(_prefix.Length)) - .ToArray(); + var rootKeys = _root.Keys; + var keys = new List(rootKeys.Count); + foreach (var key in rootKeys) + { + if (TryExtractKey(key, out var extracted)) + { + keys.Add(extracted); + } + } + return keys; } } @@ -68,9 +75,14 @@ private void ForgetKeysMissingFromRoot() _keysAllowedBeforeCleanup = Math.Max(MinKeysAddedBetweenCleanups, _prefixedKeys.Count); } + private bool HasPrefix(string key) + { + return key.StartsWith(_prefix, StringComparison.Ordinal); + } + private bool TryExtractKey(string key, out string value) { - if (key.StartsWith(_prefix, StringComparison.Ordinal)) + if (HasPrefix(key)) { value = key.Substring(_prefix.Length); return true; @@ -126,7 +138,7 @@ public int Remove(Func predicate) public int RemoveAll() { - return _root.Remove(key => key.StartsWith(_prefix, StringComparison.Ordinal)); + return _root.Remove(_hasPrefix); } public void Save() diff --git a/src/Runtime/Internals/Record.cs b/src/Runtime/Internals/Record.cs index eb4524e..d35aa2d 100644 --- a/src/Runtime/Internals/Record.cs +++ b/src/Runtime/Internals/Record.cs @@ -15,7 +15,7 @@ internal class Record : Record { private static readonly bool _valueCanBeReactiveCollection = typeof(IReactiveCollection).IsAssignableFrom(typeof(T)); - public override Type Type { get; } + public override Type Type => typeof(T); public override int TypeIndex { get; } public override Object Object => Value; public T Value { get; set; } @@ -27,7 +27,6 @@ public override IReactiveCollection AsReactiveCollection() public Record(T value, int typeIndex) { - Type = typeof(T); TypeIndex = typeIndex; Value = value; } diff --git a/src/Runtime/Persistence/BackgroundStorageWriter.cs b/src/Runtime/Persistence/BackgroundStorageWriter.cs index 832dec0..6e6c69a 100644 --- a/src/Runtime/Persistence/BackgroundStorageWriter.cs +++ b/src/Runtime/Persistence/BackgroundStorageWriter.cs @@ -11,18 +11,41 @@ internal sealed class BackgroundStorageWriter : IStorageWriter private static readonly BlockingCollection _scheduled = new(); private static readonly object _threadLock = new(); + private static readonly SendOrPostCallback _invokeAction = state => ((Action)state)(); private static Thread _thread; private readonly StorageFile _file; private readonly object _lock = new(); + private readonly SynchronizationContext _context; + private readonly Action _saveDeferredChanges; private StorageSnapshot? _pending; private bool _isScheduled; private bool _isPublishing; + private bool _isSaveDeferred; - public BackgroundStorageWriter(StorageFile file) + public BackgroundStorageWriter(StorageFile file, Action saveDeferredChanges) { _file = file; + _context = SynchronizationContext.Current; + _saveDeferredChanges = saveDeferredChanges; + } + + public bool TryDeferSave() + { + if (_context == null) + { + return false; + } + lock (_lock) + { + if (_pending == null && !_isPublishing) + { + return false; + } + _isSaveDeferred = true; + return true; + } } public void Write(StorageSnapshot snapshot, bool waitForDisk) @@ -70,6 +93,7 @@ private void Schedule(StorageSnapshot snapshot) { Monitor.Wait(_lock); } + _isSaveDeferred = false; var pending = _pending; _pending = null; return pending; @@ -91,9 +115,11 @@ private void PublishScheduled() _isPublishing = true; } + var published = false; try { _file.Publish(snapshot); + published = true; } catch (Exception exception) { @@ -101,11 +127,18 @@ private void PublishScheduled() } finally { + bool saveDeferredChanges; lock (_lock) { _isPublishing = false; + saveDeferredChanges = _isSaveDeferred && published; + _isSaveDeferred = false; Monitor.PulseAll(_lock); } + if (saveDeferredChanges) + { + _context.Post(_invokeAction, _saveDeferredChanges); + } } } diff --git a/src/Runtime/Persistence/IStorageWriter.cs b/src/Runtime/Persistence/IStorageWriter.cs index fef711a..6824d68 100644 --- a/src/Runtime/Persistence/IStorageWriter.cs +++ b/src/Runtime/Persistence/IStorageWriter.cs @@ -2,6 +2,8 @@ namespace Appegy.Storage { internal interface IStorageWriter { + bool TryDeferSave(); + void Write(StorageSnapshot snapshot, bool waitForDisk); void Flush(); diff --git a/src/Runtime/Persistence/ImmediateStorageWriter.cs b/src/Runtime/Persistence/ImmediateStorageWriter.cs index c72a53b..0f56073 100644 --- a/src/Runtime/Persistence/ImmediateStorageWriter.cs +++ b/src/Runtime/Persistence/ImmediateStorageWriter.cs @@ -9,6 +9,11 @@ public ImmediateStorageWriter(StorageFile file) _file = file; } + public bool TryDeferSave() + { + return false; + } + public void Write(StorageSnapshot snapshot, bool waitForDisk) { _file.Publish(snapshot); diff --git a/src/Runtime/Persistence/StoragePersistence.cs b/src/Runtime/Persistence/StoragePersistence.cs index e42de34..df8b021 100644 --- a/src/Runtime/Persistence/StoragePersistence.cs +++ b/src/Runtime/Persistence/StoragePersistence.cs @@ -13,11 +13,18 @@ internal sealed class StoragePersistence public bool SaveJsonCopyForDebug { get; set; } - public StoragePersistence(string filePath, IReadOnlyList sections, bool saveOnBackgroundThread) + internal int SerializeCount => _serializer.SerializeCount; + + public StoragePersistence(string filePath, IReadOnlyList sections, bool saveOnBackgroundThread, Action saveDeferredChanges) { _file = StorageFile.Of(filePath); _serializer = new StorageSerializer(sections); - _writer = saveOnBackgroundThread ? new BackgroundStorageWriter(_file) : new ImmediateStorageWriter(_file); + _writer = saveOnBackgroundThread ? new BackgroundStorageWriter(_file, saveDeferredChanges) : new ImmediateStorageWriter(_file); + } + + public bool TryDeferSave() + { + return _writer.TryDeferSave(); } public void Load(Dictionary data, KeyLoadFailedBehaviour keyLoadFailedBehaviour) diff --git a/src/Runtime/Persistence/StorageSerializer.cs b/src/Runtime/Persistence/StorageSerializer.cs index e3f92ca..e545b38 100644 --- a/src/Runtime/Persistence/StorageSerializer.cs +++ b/src/Runtime/Persistence/StorageSerializer.cs @@ -18,8 +18,11 @@ public StorageSerializer(IReadOnlyList sections) internal int BufferCapacity => _stream.Capacity; + internal int SerializeCount { get; private set; } + public StorageSnapshot Serialize(Dictionary data) { + SerializeCount++; if (data.Count == 0) { return StorageSnapshot.Empty; diff --git a/src/Runtime/Utilities/CollectionExtensions.cs b/src/Runtime/Utilities/CollectionExtensions.cs deleted file mode 100644 index e8d290a..0000000 --- a/src/Runtime/Utilities/CollectionExtensions.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Appegy.Storage -{ - internal static class CollectionExtensions - { - #region AddRange - - public static void AddRange(this ICollection source, T item1, T item2) - { - source.Add(item1); - source.Add(item2); - } - - public static void AddRange(this ICollection source, T item1, T item2, T item3) - { - source.Add(item1); - source.Add(item2); - source.Add(item3); - } - - public static void AddRange(this ICollection source, T item1, T item2, T item3, T item4) - { - source.Add(item1); - source.Add(item2); - source.Add(item3); - source.Add(item4); - } - - public static void AddRange(this ICollection source, params T[] items) - { - items.ForEach(source.Add); - } - - public static void AddRange(this IDictionary source, (TKey Key, TValue Value) item1, (TKey Key, TValue Value) item2) - { - source.Add(item1.Key, item1.Value); - source.Add(item2.Key, item2.Value); - } - - public static void AddRange(this IDictionary source, (TKey Key, TValue Value) item1, (TKey Key, TValue Value) item2, (TKey Key, TValue Value) item3) - { - source.Add(item1.Key, item1.Value); - source.Add(item2.Key, item2.Value); - source.Add(item3.Key, item3.Value); - } - - public static void AddRange(this IDictionary source, (TKey Key, TValue Value) item1, (TKey Key, TValue Value) item2, (TKey Key, TValue Value) item3, - (TKey Key, TValue Value) item4) - { - source.Add(item1.Key, item1.Value); - source.Add(item2.Key, item2.Value); - source.Add(item3.Key, item3.Value); - source.Add(item4.Key, item4.Value); - } - - public static void AddRange(this IDictionary source, params (TKey Key, TValue Value)[] items) - { - items.ForEach(item => source.Add(item.Key, item.Value)); - } - - #endregion - - public static bool IsCollection(this Type type) - { - return CollectionTypeCache.IsCollection(type); - } - - public static void ForEach(this Span source, Action predicate) - { - foreach (var item in source) - { - predicate(item); - } - } - - public static void ForEach(this IEnumerable source, Action predicate) - { - foreach (var item in source) - { - predicate(item); - } - } - - public static int FindIndex(this IEnumerable source, Func predicate) - { - var i = 0; - foreach (var item in source) - { - if (predicate(item)) - { - return i; - } - i++; - } - return -1; - } - } -} diff --git a/src/Runtime/Utilities/CollectionExtensions.cs.meta b/src/Runtime/Utilities/CollectionExtensions.cs.meta deleted file mode 100644 index f58ab83..0000000 --- a/src/Runtime/Utilities/CollectionExtensions.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 80e17a16a9f9446e86e5240c4c80fb82 -timeCreated: 1708167224 \ No newline at end of file diff --git a/src/Tests/BaseStorageTests.cs b/src/Tests/BaseStorageTests.cs index 03fd4aa..257c96b 100644 --- a/src/Tests/BaseStorageTests.cs +++ b/src/Tests/BaseStorageTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using Appegy.Storage.Serializers; using NUnit.Framework; using UnityEngine; @@ -19,10 +20,35 @@ public void CleanStorageBetweenTests() BinaryStorage.Delete(StoragePath); } + protected BinaryStorage Open(bool autoSave = false, bool saveOnBackgroundThread = true) + { + var builder = BinaryStorage.Construct(StoragePath) + .AddPrimitiveTypes() + .SaveOnBackgroundThread(saveOnBackgroundThread); + if (autoSave) + { + builder = builder.EnableAutoSaveOnChange(); + } + return builder.Build(); + } + /// Serializes and publishes the given records on the calling thread, the way a storage without a background writer does. internal static void SaveOnDisk(string filePath, IReadOnlyList sections, Dictionary data) { - new StoragePersistence(filePath, sections, false).Save(data, true); + new StoragePersistence(filePath, sections, false, () => { }).Save(data, true); + } + + protected int ReadValueFromDisk() + { + return ReadValueFrom(StoragePath); + } + + protected static int ReadValueFrom(string filePath) + { + var sections = new List { new TypedBinarySection(Int32Serializer.Shared) }; + var data = new Dictionary(); + StorageFormat.ReadFile(filePath, sections, data, KeyLoadFailedBehaviour.Ignore); + return ((Record)data["value"]).Value; } } } diff --git a/src/Tests/CollectionTests/TestCollectionExtensions.cs b/src/Tests/CollectionTests/TestCollectionExtensions.cs new file mode 100644 index 0000000..3393c90 --- /dev/null +++ b/src/Tests/CollectionTests/TestCollectionExtensions.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace Appegy.Storage +{ + internal static class TestCollectionExtensions + { + public static void AddRange(this ICollection source, params T[] items) + { + foreach (var item in items) + { + source.Add(item); + } + } + + public static void AddRange(this IDictionary source, params (TKey Key, TValue Value)[] items) + { + foreach (var item in items) + { + source.Add(item.Key, item.Value); + } + } + } +} diff --git a/src/Tests/CollectionTests/TestCollectionExtensions.cs.meta b/src/Tests/CollectionTests/TestCollectionExtensions.cs.meta new file mode 100644 index 0000000..c8823d2 --- /dev/null +++ b/src/Tests/CollectionTests/TestCollectionExtensions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 804dfad480d1ee24eb69f99a12c66cd8 \ No newline at end of file diff --git a/src/Tests/Persistence/BackgroundWriterTests.cs b/src/Tests/Persistence/BackgroundWriterTests.cs index bdcdc4c..1be1eaf 100644 --- a/src/Tests/Persistence/BackgroundWriterTests.cs +++ b/src/Tests/Persistence/BackgroundWriterTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.IO; -using Appegy.Storage.Serializers; using FluentAssertions; using NUnit.Framework; @@ -70,11 +68,7 @@ public void WhenStorageEmptied_ThenFilesAreRemoved() [Test] public void WhenBackgroundWriterDisabled_ThenAutoSaveWritesBeforeSetReturns() { - using var storage = BinaryStorage.Construct(StoragePath) - .AddPrimitiveTypes() - .EnableAutoSaveOnChange() - .SaveOnBackgroundThread(false) - .Build(); + using var storage = Open(autoSave: true, saveOnBackgroundThread: false); storage.Set("value", 11); @@ -110,28 +104,5 @@ public void WhenReopenedAfterBackgroundSave_ThenDataSurvives() reopened.Get("value").Should().Be(99); reopened.Get("text").Should().Be("kept"); } - - private BinaryStorage Open(bool autoSave = false) - { - var builder = BinaryStorage.Construct(StoragePath).AddPrimitiveTypes(); - if (autoSave) - { - builder = builder.EnableAutoSaveOnChange(); - } - return builder.Build(); - } - - private int ReadValueFromDisk() - { - return ReadValueFrom(StoragePath); - } - - private static int ReadValueFrom(string filePath) - { - var sections = new List { new TypedBinarySection(Int32Serializer.Shared) }; - var data = new Dictionary(); - StorageFormat.ReadFile(filePath, sections, data, KeyLoadFailedBehaviour.Ignore); - return ((Record)data["value"]).Value; - } } } diff --git a/src/Tests/Persistence/BackupRecoveryTests.cs b/src/Tests/Persistence/BackupRecoveryTests.cs index ed1bea7..6a93c2d 100644 --- a/src/Tests/Persistence/BackupRecoveryTests.cs +++ b/src/Tests/Persistence/BackupRecoveryTests.cs @@ -113,10 +113,5 @@ private void WriteTwoGenerations() storage.Set("generation", 2); storage.Save(); } - - private BinaryStorage Open() - { - return BinaryStorage.Construct(StoragePath).AddPrimitiveTypes().Build(); - } } } diff --git a/src/Tests/Persistence/SaveCoalescingTests.cs b/src/Tests/Persistence/SaveCoalescingTests.cs new file mode 100644 index 0000000..88376de --- /dev/null +++ b/src/Tests/Persistence/SaveCoalescingTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using FluentAssertions; +using NUnit.Framework; + +namespace Appegy.Storage +{ + public class SaveCoalescingTests : BaseStorageTests + { + private const int BurstSize = 200; + private const int WaitTimeoutMs = 10000; + private const int PollIntervalMs = 5; + private const int WriterSettleMs = 200; + + [Test] + public void WhenWriterIsIdle_ThenChangeIsSerializedRightAway() + { + using var storage = Open(autoSave: true); + + storage.Set("value", 1); + + storage.SerializeCount.Should().Be(1); + } + + [Test] + public void WhenPreviousSnapshotIsStillOnItsWay_ThenChangesAreCoalesced() + { + using var storage = Open(autoSave: true); + + Burst(storage); + + storage.SerializeCount.Should().BeLessThan(BurstSize); + } + + [Test] + public void WhenBurstCoalesced_ThenExplicitSavePutsTheLastStateOnDisk() + { + using var storage = Open(autoSave: true); + + Burst(storage); + storage.Save(); + + ReadValueFromDisk().Should().Be(BurstSize); + } + + [Test] + public void WhenChangesDeferred_ThenTheyReachDiskWithoutExplicitSave() + { + var context = new PumpableSynchronizationContext(); + using var storage = OpenWith(context); + Burst(storage); + var serializedDuringBurst = storage.SerializeCount; + + PumpUntil(context, () => storage.SerializeCount > serializedDuringBurst, "deferred changes were never serialized again"); + + WaitUntilWriterIsQuiet(); + ReadValueFromDisk().Should().Be(BurstSize); + } + + [Test] + public void WhenDeferredChangesAreStillThereOnDispose_ThenTheyReachDisk() + { + using (var storage = Open(autoSave: true)) + { + Burst(storage); + } + + ReadValueFromDisk().Should().Be(BurstSize); + } + + [Test] + public void WhenThereIsNoSynchronizationContext_ThenEveryChangeIsSerialized() + { + using var storage = OpenWith(null); + + for (var i = 1; i <= 5; i++) + { + storage.Set("value", i); + } + + storage.SerializeCount.Should().Be(5); + } + + [Test] + public void WhenChangeScopeIsOpen_ThenDeferredSaveWaitsForItsEnd() + { + var context = new PumpableSynchronizationContext(); + using var storage = OpenWith(context); + storage.Set("value", 1); + storage.Set("value", 2); + WaitUntilWriterIsQuiet(); + var serializedBeforeScope = storage.SerializeCount; + + using (storage.MultipleChangeScope()) + { + storage.Set("value", 3); + context.Pump(); + + storage.SerializeCount.Should().Be(serializedBeforeScope); + } + + storage.SerializeCount.Should().Be(serializedBeforeScope + 1); + storage.Save(); + ReadValueFromDisk().Should().Be(3); + } + + [Test] + public void WhenBackgroundWriterDisabled_ThenNothingIsDeferred() + { + using var storage = Open(autoSave: true, saveOnBackgroundThread: false); + + for (var i = 1; i <= 5; i++) + { + storage.Set("value", i); + } + + storage.SerializeCount.Should().Be(5); + ReadValueFromDisk().Should().Be(5); + } + + private BinaryStorage OpenWith(SynchronizationContext context) + { + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + return Open(autoSave: true); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + } + + private static void Burst(BinaryStorage storage) + { + for (var i = 1; i <= BurstSize; i++) + { + storage.Set("value", i); + } + } + + private static void PumpUntil(PumpableSynchronizationContext context, Func condition, string message) + { + var deadline = Environment.TickCount + WaitTimeoutMs; + while (Environment.TickCount < deadline) + { + context.Pump(); + if (condition()) + { + return; + } + Thread.Sleep(PollIntervalMs); + } + Assert.Fail(message); + } + + private void WaitUntilWriterIsQuiet() + { + var deadline = Environment.TickCount + WaitTimeoutMs; + while (Environment.TickCount < deadline && File.Exists(TempPath)) + { + Thread.Sleep(PollIntervalMs); + } + Thread.Sleep(WriterSettleMs); + } + + private sealed class PumpableSynchronizationContext : SynchronizationContext + { + private readonly ConcurrentQueue> _posted = new(); + + public override void Post(SendOrPostCallback callback, object state) + { + _posted.Enqueue(new KeyValuePair(callback, state)); + } + + public void Pump() + { + while (_posted.TryDequeue(out var work)) + { + work.Key(work.Value); + } + } + } + } +} diff --git a/src/Tests/Persistence/SaveCoalescingTests.cs.meta b/src/Tests/Persistence/SaveCoalescingTests.cs.meta new file mode 100644 index 0000000..2810022 --- /dev/null +++ b/src/Tests/Persistence/SaveCoalescingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d9b8bd5c9cadcfc448715b89c7d5b740 \ No newline at end of file