From cc8abea8ea486959bd5e3e0910419ec2ac78bc9f Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Mon, 10 Aug 2026 21:52:00 +0200 Subject: [PATCH 1/4] perf: coalesce auto-save serialization while a write is in flight While the previous snapshot is still on its way to the disk, a change no longer serializes the whole storage: it only marks the storage as changed. The background writer asks the storage to serialize once more when it becomes free, through the SynchronizationContext captured at build time. --- CHANGELOG.md | 1 + src/Runtime/BinaryStorage.cs | 17 ++ .../Persistence/BackgroundStorageWriter.cs | 34 +++ src/Runtime/Persistence/IStorageWriter.cs | 6 + .../Persistence/ImmediateStorageWriter.cs | 9 + src/Runtime/Persistence/StoragePersistence.cs | 13 ++ src/Runtime/Persistence/StorageSerializer.cs | 3 + src/Tests/BaseStorageTests.cs | 14 ++ .../Persistence/BackgroundWriterTests.cs | 15 -- src/Tests/Persistence/SaveCoalescingTests.cs | 202 ++++++++++++++++++ .../Persistence/SaveCoalescingTests.cs.meta | 2 + 11 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 src/Tests/Persistence/SaveCoalescingTests.cs create mode 100644 src/Tests/Persistence/SaveCoalescingTests.cs.meta 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..cd7571e 100644 --- a/src/Runtime/BinaryStorage.cs +++ b/src/Runtime/BinaryStorage.cs @@ -37,6 +37,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. @@ -46,6 +48,7 @@ internal BinaryStorage(string storageFilePath, IReadOnlyList supp _storageFilePath = storageFilePath; _supportedTypes = supportedTypes; _persistence = new StoragePersistence(storageFilePath, supportedTypes, saveOnBackgroundThread); + _persistence.SaveDeferredChanges = SaveDeferredChanges; } #region Events @@ -747,10 +750,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 || !AutoSave || !_hasUnsavedChanges || _changeScopeCounter > 0) + { + return; + } + SaveDataOnDisk(false); + } + #endregion } } diff --git a/src/Runtime/Persistence/BackgroundStorageWriter.cs b/src/Runtime/Persistence/BackgroundStorageWriter.cs index 832dec0..1138dab 100644 --- a/src/Runtime/Persistence/BackgroundStorageWriter.cs +++ b/src/Runtime/Persistence/BackgroundStorageWriter.cs @@ -15,14 +15,38 @@ internal sealed class BackgroundStorageWriter : IStorageWriter private readonly StorageFile _file; private readonly object _lock = new(); + private readonly SynchronizationContext _context; + private readonly SendOrPostCallback _saveDeferredChanges; private StorageSnapshot? _pending; private bool _isScheduled; private bool _isPublishing; + private bool _isSaveDeferred; + + public Action SaveDeferredChanges { get; set; } public BackgroundStorageWriter(StorageFile file) { _file = file; + _context = SynchronizationContext.Current; + _saveDeferredChanges = _ => SaveDeferredChanges?.Invoke(); + } + + 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 +94,7 @@ private void Schedule(StorageSnapshot snapshot) { Monitor.Wait(_lock); } + _isSaveDeferred = false; var pending = _pending; _pending = null; return pending; @@ -91,9 +116,11 @@ private void PublishScheduled() _isPublishing = true; } + var published = false; try { _file.Publish(snapshot); + published = true; } catch (Exception exception) { @@ -101,11 +128,18 @@ private void PublishScheduled() } finally { + bool saveDeferredChanges; lock (_lock) { _isPublishing = false; + saveDeferredChanges = _isSaveDeferred && published; + _isSaveDeferred = false; Monitor.PulseAll(_lock); } + if (saveDeferredChanges) + { + _context.Post(_saveDeferredChanges, null); + } } } diff --git a/src/Runtime/Persistence/IStorageWriter.cs b/src/Runtime/Persistence/IStorageWriter.cs index fef711a..dbd8b42 100644 --- a/src/Runtime/Persistence/IStorageWriter.cs +++ b/src/Runtime/Persistence/IStorageWriter.cs @@ -1,7 +1,13 @@ +using System; + namespace Appegy.Storage { internal interface IStorageWriter { + Action SaveDeferredChanges { get; set; } + + 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..e5b05f2 100644 --- a/src/Runtime/Persistence/ImmediateStorageWriter.cs +++ b/src/Runtime/Persistence/ImmediateStorageWriter.cs @@ -1,14 +1,23 @@ +using System; + namespace Appegy.Storage { internal sealed class ImmediateStorageWriter : IStorageWriter { private readonly StorageFile _file; + public Action SaveDeferredChanges { get; set; } + 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..3eb8dcb 100644 --- a/src/Runtime/Persistence/StoragePersistence.cs +++ b/src/Runtime/Persistence/StoragePersistence.cs @@ -13,6 +13,14 @@ internal sealed class StoragePersistence public bool SaveJsonCopyForDebug { get; set; } + public Action SaveDeferredChanges + { + get => _writer.SaveDeferredChanges; + set => _writer.SaveDeferredChanges = value; + } + + internal int SerializeCount => _serializer.SerializeCount; + public StoragePersistence(string filePath, IReadOnlyList sections, bool saveOnBackgroundThread) { _file = StorageFile.Of(filePath); @@ -20,6 +28,11 @@ public StoragePersistence(string filePath, IReadOnlyList sections _writer = saveOnBackgroundThread ? new BackgroundStorageWriter(_file) : new ImmediateStorageWriter(_file); } + public bool TryDeferSave() + { + return _writer.TryDeferSave(); + } + public void Load(Dictionary data, KeyLoadFailedBehaviour keyLoadFailedBehaviour) { _serializer.Clear(data); 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/Tests/BaseStorageTests.cs b/src/Tests/BaseStorageTests.cs index 03fd4aa..6d1656d 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; @@ -24,5 +25,18 @@ internal static void SaveOnDisk(string filePath, IReadOnlyList se { 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/Persistence/BackgroundWriterTests.cs b/src/Tests/Persistence/BackgroundWriterTests.cs index bdcdc4c..2a58d0c 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; @@ -120,18 +118,5 @@ private BinaryStorage Open(bool autoSave = false) } 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/SaveCoalescingTests.cs b/src/Tests/Persistence/SaveCoalescingTests.cs new file mode 100644 index 0000000..6a96296 --- /dev/null +++ b/src/Tests/Persistence/SaveCoalescingTests.cs @@ -0,0 +1,202 @@ +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(); + + storage.Set("value", 1); + + storage.SerializeCount.Should().Be(1); + } + + [Test] + public void WhenPreviousSnapshotIsStillOnItsWay_ThenChangesAreCoalesced() + { + using var storage = Open(); + + for (var i = 1; i <= BurstSize; i++) + { + storage.Set("value", i); + } + + storage.SerializeCount.Should().BeLessThan(BurstSize); + } + + [Test] + public void WhenBurstCoalesced_ThenExplicitSavePutsTheLastStateOnDisk() + { + using var storage = Open(); + + for (var i = 1; i <= BurstSize; i++) + { + storage.Set("value", i); + } + storage.Save(); + + ReadValueFromDisk().Should().Be(BurstSize); + } + + [Test] + public void WhenChangesDeferred_ThenTheyReachDiskWithoutExplicitSave() + { + var context = new PumpableSynchronizationContext(); + using var storage = OpenWith(context); + for (var i = 1; i <= BurstSize; i++) + { + storage.Set("value", i); + } + 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()) + { + for (var i = 1; i <= BurstSize; i++) + { + storage.Set("value", i); + } + } + + 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 = BinaryStorage.Construct(StoragePath) + .AddPrimitiveTypes() + .EnableAutoSaveOnChange() + .SaveOnBackgroundThread(false) + .Build(); + + for (var i = 1; i <= 5; i++) + { + storage.Set("value", i); + } + + storage.SerializeCount.Should().Be(5); + ReadValueFromDisk().Should().Be(5); + } + + private BinaryStorage Open() + { + return BinaryStorage.Construct(StoragePath).AddPrimitiveTypes().EnableAutoSaveOnChange().Build(); + } + + private BinaryStorage OpenWith(SynchronizationContext context) + { + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + return Open(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + } + + 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 From bcdf0c4683a16e83711fcf036aec8341a425e010 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Mon, 10 Aug 2026 22:24:48 +0200 Subject: [PATCH 2/4] refactor: collapse duplicated record and section handling in BinaryStorage Look up a registered section through a type-to-index dictionary instead of scanning the section list twice with two different comparisons. Share the type-mismatch decision between Set and SetRaw, and keep reactive collection tracking in one pair of methods instead of five copies. Hand the deferred-save callback to the writer at construction so it stops travelling through a settable property on three types, and move the test-only AddRange helpers out of the runtime assembly. --- src/Runtime/BinaryStorage.cs | 165 +++++++++--------- src/Runtime/Internals/Record.cs | 3 +- .../Persistence/BackgroundStorageWriter.cs | 6 +- src/Runtime/Persistence/IStorageWriter.cs | 4 - .../Persistence/ImmediateStorageWriter.cs | 4 - src/Runtime/Persistence/StoragePersistence.cs | 10 +- src/Runtime/Utilities/CollectionExtensions.cs | 100 ----------- .../Utilities/CollectionExtensions.cs.meta | 3 - src/Tests/BaseStorageTests.cs | 2 +- .../TestCollectionExtensions.cs | 23 +++ .../TestCollectionExtensions.cs.meta | 2 + 11 files changed, 110 insertions(+), 212 deletions(-) delete mode 100644 src/Runtime/Utilities/CollectionExtensions.cs delete mode 100644 src/Runtime/Utilities/CollectionExtensions.cs.meta create mode 100644 src/Tests/CollectionTests/TestCollectionExtensions.cs create mode 100644 src/Tests/CollectionTests/TestCollectionExtensions.cs.meta diff --git a/src/Runtime/BinaryStorage.cs b/src/Runtime/BinaryStorage.cs index cd7571e..3df6b36 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; @@ -47,8 +49,13 @@ internal BinaryStorage(string storageFilePath, IReadOnlyList supp { _storageFilePath = storageFilePath; _supportedTypes = supportedTypes; - _persistence = new StoragePersistence(storageFilePath, supportedTypes, saveOnBackgroundThread); - _persistence.SaveDeferredChanges = SaveDeferredChanges; + _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 @@ -107,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); } @@ -131,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. @@ -239,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; } /// @@ -323,7 +318,7 @@ public IDisposable MultipleChangeScope() { ThrowIfDisposed(); _changeScopeCounter++; - return new DisposableScope(DecreaseCounter); + return new DisposableScope(_decreaseCounter); } #region Collections @@ -451,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; @@ -469,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); @@ -521,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(); @@ -543,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++) @@ -576,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); } /// @@ -692,14 +697,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; @@ -734,12 +732,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); } } 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 1138dab..e44d551 100644 --- a/src/Runtime/Persistence/BackgroundStorageWriter.cs +++ b/src/Runtime/Persistence/BackgroundStorageWriter.cs @@ -23,13 +23,11 @@ internal sealed class BackgroundStorageWriter : IStorageWriter private bool _isPublishing; private bool _isSaveDeferred; - public Action SaveDeferredChanges { get; set; } - - public BackgroundStorageWriter(StorageFile file) + public BackgroundStorageWriter(StorageFile file, Action saveDeferredChanges) { _file = file; _context = SynchronizationContext.Current; - _saveDeferredChanges = _ => SaveDeferredChanges?.Invoke(); + _saveDeferredChanges = _ => saveDeferredChanges(); } public bool TryDeferSave() diff --git a/src/Runtime/Persistence/IStorageWriter.cs b/src/Runtime/Persistence/IStorageWriter.cs index dbd8b42..6824d68 100644 --- a/src/Runtime/Persistence/IStorageWriter.cs +++ b/src/Runtime/Persistence/IStorageWriter.cs @@ -1,11 +1,7 @@ -using System; - namespace Appegy.Storage { internal interface IStorageWriter { - Action SaveDeferredChanges { get; set; } - bool TryDeferSave(); void Write(StorageSnapshot snapshot, bool waitForDisk); diff --git a/src/Runtime/Persistence/ImmediateStorageWriter.cs b/src/Runtime/Persistence/ImmediateStorageWriter.cs index e5b05f2..0f56073 100644 --- a/src/Runtime/Persistence/ImmediateStorageWriter.cs +++ b/src/Runtime/Persistence/ImmediateStorageWriter.cs @@ -1,13 +1,9 @@ -using System; - namespace Appegy.Storage { internal sealed class ImmediateStorageWriter : IStorageWriter { private readonly StorageFile _file; - public Action SaveDeferredChanges { get; set; } - public ImmediateStorageWriter(StorageFile file) { _file = file; diff --git a/src/Runtime/Persistence/StoragePersistence.cs b/src/Runtime/Persistence/StoragePersistence.cs index 3eb8dcb..df8b021 100644 --- a/src/Runtime/Persistence/StoragePersistence.cs +++ b/src/Runtime/Persistence/StoragePersistence.cs @@ -13,19 +13,13 @@ internal sealed class StoragePersistence public bool SaveJsonCopyForDebug { get; set; } - public Action SaveDeferredChanges - { - get => _writer.SaveDeferredChanges; - set => _writer.SaveDeferredChanges = value; - } - internal int SerializeCount => _serializer.SerializeCount; - public StoragePersistence(string filePath, IReadOnlyList sections, bool saveOnBackgroundThread) + 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() 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 6d1656d..47419e2 100644 --- a/src/Tests/BaseStorageTests.cs +++ b/src/Tests/BaseStorageTests.cs @@ -23,7 +23,7 @@ public void CleanStorageBetweenTests() /// 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() 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 From 29469b718a20260d41a4e5b37c19ab1024a631ea Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Mon, 10 Aug 2026 22:28:20 +0200 Subject: [PATCH 3/4] refactor: give reactive collections a shared base ReactiveList, ReactiveSet and ReactiveDictionary each carried their own copy of the dispose flag, the OnChanged event, SetDirty and ThrowIfDisposed. Move that contract into ReactiveCollection so a change to it lands in one place. Build the nested storage key list in a single pass instead of a LINQ chain, and reuse the prefix check that RemoveAll already needed. --- src/Runtime/Collections/ReactiveCollection.cs | 38 +++++++++++++++++++ .../Collections/ReactiveCollection.cs.meta | 2 + src/Runtime/Collections/ReactiveDictionary.cs | 34 ++--------------- src/Runtime/Collections/ReactiveList.cs | 32 ++-------------- src/Runtime/Collections/ReactiveSet.cs | 34 ++--------------- src/Runtime/Internals/NestedBinaryStorage.cs | 23 +++++++---- 6 files changed, 67 insertions(+), 96 deletions(-) create mode 100644 src/Runtime/Collections/ReactiveCollection.cs create mode 100644 src/Runtime/Collections/ReactiveCollection.cs.meta 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..2b45b2c 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 @@ -25,10 +24,15 @@ public IReadOnlyCollection Keys { get { - return _root.Keys - .Where(k => k.StartsWith(_prefix, StringComparison.Ordinal)) - .Select(k => k.Substring(_prefix.Length)) - .ToArray(); + var keys = new List(); + foreach (var key in _root.Keys) + { + if (TryExtractKey(key, out var extracted)) + { + keys.Add(extracted); + } + } + return keys; } } @@ -68,9 +72,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 +135,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() From f11baa88d5f7223a10a8a8078257180490b064f1 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Mon, 10 Aug 2026 22:37:19 +0200 Subject: [PATCH 4/4] refactor: keep the auto-save decision in one place MarkChanged, DecreaseCounter and SaveDeferredChanges each carried their own copy of the "auto-save is on, no change scope is open, there is something to save" condition. Route the other two through MarkChanged so the policy lives in one method. Give the nested storage key list its capacity up front and reuse a cached prefix predicate, post the deferred-save callback through a static callback instead of a closure, and share one storage-opening helper across the persistence fixtures. --- src/Runtime/BinaryStorage.cs | 13 ++--- src/Runtime/Internals/NestedBinaryStorage.cs | 9 ++-- .../Persistence/BackgroundStorageWriter.cs | 7 +-- src/Tests/BaseStorageTests.cs | 12 +++++ .../Persistence/BackgroundWriterTests.cs | 16 +----- src/Tests/Persistence/BackupRecoveryTests.cs | 5 -- src/Tests/Persistence/SaveCoalescingTests.cs | 49 +++++++------------ 7 files changed, 44 insertions(+), 67 deletions(-) diff --git a/src/Runtime/BinaryStorage.cs b/src/Runtime/BinaryStorage.cs index 3df6b36..593a5e8 100644 --- a/src/Runtime/BinaryStorage.cs +++ b/src/Runtime/BinaryStorage.cs @@ -602,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. @@ -754,11 +747,11 @@ private void SaveDataOnDisk(bool waitForDisk) private void SaveDeferredChanges() { - if (IsDisposed || !AutoSave || !_hasUnsavedChanges || _changeScopeCounter > 0) + if (IsDisposed || !_hasUnsavedChanges) { return; } - SaveDataOnDisk(false); + MarkChanged(); } #endregion diff --git a/src/Runtime/Internals/NestedBinaryStorage.cs b/src/Runtime/Internals/NestedBinaryStorage.cs index 2b45b2c..a08d99b 100644 --- a/src/Runtime/Internals/NestedBinaryStorage.cs +++ b/src/Runtime/Internals/NestedBinaryStorage.cs @@ -10,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; @@ -18,14 +19,16 @@ public NestedBinaryStorage(IBinaryStorage root, string prefix) { _prefix = $"__{prefix}->"; _root = root; + _hasPrefix = HasPrefix; } public IReadOnlyCollection Keys { get { - var keys = new List(); - foreach (var key in _root.Keys) + var rootKeys = _root.Keys; + var keys = new List(rootKeys.Count); + foreach (var key in rootKeys) { if (TryExtractKey(key, out var extracted)) { @@ -135,7 +138,7 @@ public int Remove(Func predicate) public int RemoveAll() { - return _root.Remove(HasPrefix); + return _root.Remove(_hasPrefix); } public void Save() diff --git a/src/Runtime/Persistence/BackgroundStorageWriter.cs b/src/Runtime/Persistence/BackgroundStorageWriter.cs index e44d551..6e6c69a 100644 --- a/src/Runtime/Persistence/BackgroundStorageWriter.cs +++ b/src/Runtime/Persistence/BackgroundStorageWriter.cs @@ -11,12 +11,13 @@ 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 SendOrPostCallback _saveDeferredChanges; + private readonly Action _saveDeferredChanges; private StorageSnapshot? _pending; private bool _isScheduled; @@ -27,7 +28,7 @@ public BackgroundStorageWriter(StorageFile file, Action saveDeferredChanges) { _file = file; _context = SynchronizationContext.Current; - _saveDeferredChanges = _ => saveDeferredChanges(); + _saveDeferredChanges = saveDeferredChanges; } public bool TryDeferSave() @@ -136,7 +137,7 @@ private void PublishScheduled() } if (saveDeferredChanges) { - _context.Post(_saveDeferredChanges, null); + _context.Post(_invokeAction, _saveDeferredChanges); } } } diff --git a/src/Tests/BaseStorageTests.cs b/src/Tests/BaseStorageTests.cs index 47419e2..257c96b 100644 --- a/src/Tests/BaseStorageTests.cs +++ b/src/Tests/BaseStorageTests.cs @@ -20,6 +20,18 @@ 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) { diff --git a/src/Tests/Persistence/BackgroundWriterTests.cs b/src/Tests/Persistence/BackgroundWriterTests.cs index 2a58d0c..1be1eaf 100644 --- a/src/Tests/Persistence/BackgroundWriterTests.cs +++ b/src/Tests/Persistence/BackgroundWriterTests.cs @@ -68,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); @@ -108,15 +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(); - } } } 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 index 6a96296..88376de 100644 --- a/src/Tests/Persistence/SaveCoalescingTests.cs +++ b/src/Tests/Persistence/SaveCoalescingTests.cs @@ -18,7 +18,7 @@ public class SaveCoalescingTests : BaseStorageTests [Test] public void WhenWriterIsIdle_ThenChangeIsSerializedRightAway() { - using var storage = Open(); + using var storage = Open(autoSave: true); storage.Set("value", 1); @@ -28,12 +28,9 @@ public void WhenWriterIsIdle_ThenChangeIsSerializedRightAway() [Test] public void WhenPreviousSnapshotIsStillOnItsWay_ThenChangesAreCoalesced() { - using var storage = Open(); + using var storage = Open(autoSave: true); - for (var i = 1; i <= BurstSize; i++) - { - storage.Set("value", i); - } + Burst(storage); storage.SerializeCount.Should().BeLessThan(BurstSize); } @@ -41,12 +38,9 @@ public void WhenPreviousSnapshotIsStillOnItsWay_ThenChangesAreCoalesced() [Test] public void WhenBurstCoalesced_ThenExplicitSavePutsTheLastStateOnDisk() { - using var storage = Open(); + using var storage = Open(autoSave: true); - for (var i = 1; i <= BurstSize; i++) - { - storage.Set("value", i); - } + Burst(storage); storage.Save(); ReadValueFromDisk().Should().Be(BurstSize); @@ -57,10 +51,7 @@ public void WhenChangesDeferred_ThenTheyReachDiskWithoutExplicitSave() { var context = new PumpableSynchronizationContext(); using var storage = OpenWith(context); - for (var i = 1; i <= BurstSize; i++) - { - storage.Set("value", i); - } + Burst(storage); var serializedDuringBurst = storage.SerializeCount; PumpUntil(context, () => storage.SerializeCount > serializedDuringBurst, "deferred changes were never serialized again"); @@ -72,12 +63,9 @@ public void WhenChangesDeferred_ThenTheyReachDiskWithoutExplicitSave() [Test] public void WhenDeferredChangesAreStillThereOnDispose_ThenTheyReachDisk() { - using (var storage = Open()) + using (var storage = Open(autoSave: true)) { - for (var i = 1; i <= BurstSize; i++) - { - storage.Set("value", i); - } + Burst(storage); } ReadValueFromDisk().Should().Be(BurstSize); @@ -122,11 +110,7 @@ public void WhenChangeScopeIsOpen_ThenDeferredSaveWaitsForItsEnd() [Test] public void WhenBackgroundWriterDisabled_ThenNothingIsDeferred() { - using var storage = BinaryStorage.Construct(StoragePath) - .AddPrimitiveTypes() - .EnableAutoSaveOnChange() - .SaveOnBackgroundThread(false) - .Build(); + using var storage = Open(autoSave: true, saveOnBackgroundThread: false); for (var i = 1; i <= 5; i++) { @@ -137,18 +121,13 @@ public void WhenBackgroundWriterDisabled_ThenNothingIsDeferred() ReadValueFromDisk().Should().Be(5); } - private BinaryStorage Open() - { - return BinaryStorage.Construct(StoragePath).AddPrimitiveTypes().EnableAutoSaveOnChange().Build(); - } - private BinaryStorage OpenWith(SynchronizationContext context) { var previous = SynchronizationContext.Current; SynchronizationContext.SetSynchronizationContext(context); try { - return Open(); + return Open(autoSave: true); } finally { @@ -156,6 +135,14 @@ private BinaryStorage OpenWith(SynchronizationContext context) } } + 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;