diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 6af00795..1fdf07c8 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -201,10 +201,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore } Entries[pair.Key] = pair.Value; - if (existing == null) - EntryAdded.OnNext(pair.Value); - else - EntryUpdated.OnNext(pair.Value); + Loaded?.Invoke(pair.Value); } foreach (var removedKey in _hydratedKeys.Where(key => !loaded.ContainsKey(key)).ToArray()) @@ -213,7 +210,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore continue; if (Entries.TryRemove(removedKey, out var removed)) - EntryDeleted.OnNext(removed); + Unloaded?.Invoke(removed); _hydratedKeys.Remove(removedKey); } @@ -307,8 +304,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore /// public override void CommitBatch( IReadOnlyCollection upserts, - IReadOnlyCollection deletes, - bool soft) + IReadOnlyCollection deletes) { lock (_mutationGate) { @@ -331,7 +327,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore _deletedKeys.TryRemove(entry.Key.Value, out _); } IsDirty = true; - PushAllCore(soft); + PushAllCore(); } catch { @@ -351,13 +347,13 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore } /// - public override void PushAll(bool soft = false) + public override void PushAll() { lock (_mutationGate) - PushAllCore(soft); + PushAllCore(); } - private void PushAllCore(bool soft) + private void PushAllCore() { if (!IsDirty && !_needsIndexUpgrade) { @@ -652,10 +648,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore continue; Entries[pair.Key] = pair.Value; - if (existing == null) - EntryAdded.OnNext(pair.Value); - else - EntryUpdated.OnNext(pair.Value); + Loaded?.Invoke(pair.Value); } foreach (var missingKey in selectedKeys.Where(key => !loaded.ContainsKey(key)).ToArray()) @@ -663,7 +656,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore if (_dirtyKeys.ContainsKey(missingKey) || _deletedKeys.ContainsKey(missingKey)) continue; if (Entries.TryRemove(missingKey, out var removed)) - EntryDeleted.OnNext(removed); + Unloaded?.Invoke(removed); _hydratedKeys.Remove(missingKey); } diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index a722d965..7f1af80e 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -9,7 +9,6 @@ using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; -using GameCult.Logging; using R3; namespace GameCult.Caching @@ -1281,7 +1280,6 @@ namespace GameCult.Caching private readonly SemaphoreSlim _transactionGate = new(1, 1); private readonly AsyncLocal _ambientTransaction = new(); private readonly object _stateGate = new(); - private ILogger _logger = new NullLogger(); private bool _hasUnflushedMutations; /// @@ -1314,25 +1312,11 @@ namespace GameCult.Caching InitializeGlobals(); } - /// - /// Gets or sets the cache logger. - /// - public ILogger Logger - { - get => _logger; - set => _logger = value ?? new NullLogger(); - } - /// /// Gets whether the cache currently holds unflushed mutations in any attached backing store or only in memory. /// public bool IsDirty => _hasUnflushedMutations || _backingStores.Any(store => store.IsDirty); - /// - /// Gets the UTC timestamp of the last successful flush across all attached backing stores. - /// - public DateTimeOffset? LastSuccessfulFlushAtUtc { get; private set; } - /// /// Gets or sets whether disposing the cache should flush attached dirty backing stores first. /// @@ -1405,10 +1389,8 @@ namespace GameCult.Caching { if (store == null) throw new ArgumentNullException(nameof(store)); store.AttachRegistry(_registry); - store.Logger = Logger; - store.EntryAdded.Subscribe(entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true).GetAwaiter().GetResult()); - store.EntryUpdated.Subscribe(entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true).GetAwaiter().GetResult()); - store.EntryDeleted.Subscribe(entry => RemoveStoredDocumentInternal(entry, store, raiseUpdate: true)); + store.Loaded = entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true); + store.Unloaded = entry => RemoveStoredDocumentInternal(entry, store, raiseUpdate: true); foreach (var entry in _entries.Values.OrderBy(entry => entry.Key.Value, StringComparer.Ordinal)) { @@ -1441,35 +1423,10 @@ namespace GameCult.Caching } } - /// - /// Pulls persisted records selected by durable metadata from every attached backing store. - /// Stores without indexed selection support preserve correctness by performing a full pull. - /// - public async Task PullBackingStoreRecordsAsync(Func selector) - { - if (selector == null) throw new ArgumentNullException(nameof(selector)); - if (_ambientTransaction.Value != null) - throw new InvalidOperationException( - "CultCache hydration cannot run inside a mutation transaction; hydrate before opening the commit scope."); - - await _transactionGate.WaitAsync().ConfigureAwait(false); - try - { - foreach (var store in _backingStores) - store.PullSelected(selector); - - _hasUnflushedMutations = _backingStores.Any(store => store.IsDirty); - } - finally - { - _transactionGate.Release(); - } - } - /// /// Flushes all attached backing stores. /// - public void FlushAllBackingStores(bool soft = false) + public void FlushAllBackingStores() { if (_ambientTransaction.Value != null) throw new InvalidOperationException("A CultCache transaction owns durable commit; do not flush inside its stage callback."); @@ -1478,11 +1435,10 @@ namespace GameCult.Caching try { foreach (var store in _backingStores) - FlushBackingStoreCore(store, soft); - - RecomputeDirtyState(); - if (!IsDirty) - LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow; + { + store.PushAll(); + RecomputeDirtyState(); + } } finally { @@ -1490,64 +1446,10 @@ namespace GameCult.Caching } } - /// - /// Flushes all attached backing stores. - /// + // soft is dead. CultCacheStudioWindow reflects on FlushAsync(bool) until Cut 6 rewrites it. public Task FlushAsync(bool soft = false) { - FlushAllBackingStores(soft); - return Task.CompletedTask; - } - - /// - /// Flushes one attached backing store. - /// - public void FlushBackingStore(CacheBackingStore store, bool soft = false) - { - if (store == null) throw new ArgumentNullException(nameof(store)); - if (!_backingStores.Contains(store)) - { - throw new InvalidOperationException("Backing store is not attached to this cache."); - } - - if (_ambientTransaction.Value != null) - throw new InvalidOperationException("A CultCache transaction owns durable commit; do not flush inside its stage callback."); - - _transactionGate.Wait(); - try - { - FlushBackingStoreCore(store, soft); - } - finally - { - _transactionGate.Release(); - } - } - - private void FlushBackingStoreCore(CacheBackingStore store, bool soft) - { - store.PushAll(soft); - RecomputeDirtyState(); - if (!store.IsDirty) - { - LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow; - } - } - - /// - /// Flushes attached backing stores at a lifecycle boundary such as shutdown or assembly reload. - /// - public void PrepareForReloadOrShutdown(bool soft = false) - { - FlushAllBackingStores(soft); - } - - /// - /// Flushes attached backing stores at a lifecycle boundary such as shutdown or assembly reload. - /// - public Task PrepareForReloadOrShutdownAsync(bool soft = false) - { - PrepareForReloadOrShutdown(soft); + FlushAllBackingStores(); return Task.CompletedTask; } @@ -1556,7 +1458,7 @@ namespace GameCult.Caching /// async flow. The durable backing store is committed before the live cache and its /// observers advance; an exception discards the entire staged batch. /// - public async Task ExecuteTransactionAsync(Func stageAsync, bool soft = false) + public async Task ExecuteTransactionAsync(Func stageAsync) { if (stageAsync == null) throw new ArgumentNullException(nameof(stageAsync)); if (_ambientTransaction.Value != null) @@ -1573,7 +1475,7 @@ namespace GameCult.Caching { await stageAsync().ConfigureAwait(false); transaction.Seal(); - changes = CommitTransaction(transaction, soft); + changes = CommitTransaction(transaction); } finally { @@ -1587,14 +1489,14 @@ namespace GameCult.Caching } /// Runs a value-producing buffered cache mutation. - public async Task ExecuteTransactionAsync(Func> stageAsync, bool soft = false) + public async Task ExecuteTransactionAsync(Func> stageAsync) { if (stageAsync == null) throw new ArgumentNullException(nameof(stageAsync)); T result = default!; await ExecuteTransactionAsync(async () => { result = await stageAsync().ConfigureAwait(false); - }, soft).ConfigureAwait(false); + }).ConfigureAwait(false); return result; } @@ -1614,10 +1516,8 @@ namespace GameCult.Caching await _transactionGate.WaitAsync().ConfigureAwait(false); try { - var stored = await AddStoredDocumentInternal( - CreateStoredDocument(typeof(T), document, handle?.Key), - source: null, - raiseUpdate: false).ConfigureAwait(false); + var stored = CreateStoredDocument(typeof(T), document, handle?.Key); + AddStoredDocumentInternal(stored, source: null, raiseUpdate: false); return new CultRecordHandle(stored.Key); } finally @@ -1658,10 +1558,8 @@ namespace GameCult.Caching await _transactionGate.WaitAsync().ConfigureAwait(false); try { - var stored = await AddStoredDocumentInternal( - CreateStoredDocument(documentType, document, key), - source: null, - raiseUpdate: false).ConfigureAwait(false); + var stored = CreateStoredDocument(documentType, document, key); + AddStoredDocumentInternal(stored, source: null, raiseUpdate: false); return stored.Key; } finally @@ -1705,33 +1603,17 @@ namespace GameCult.Caching return Get(key) as T; } - /// - /// Tries to get a typed document by record key. - /// - public bool TryGet(CultRecordKey key, out T? document) where T : class - { - document = Get(key); - return document != null; - } - /// /// Gets all cached documents assignable to the requested type. /// public IEnumerable GetAll() where T : class - { - return GetStoredDocuments().Select(entry => (T)entry.Document); - } - - /// - /// Gets typed cached records with their stable CultCache identities without scanning unrelated documents. - /// - public IEnumerable GetStoredDocuments() where T : class { lock (_stateGate) { var type = typeof(T); return VisibleStoredDocuments() .Where(entry => type.IsAssignableFrom(entry.Descriptor.DocumentType)) + .Select(entry => (T)entry.Document) .ToArray(); } } @@ -1761,15 +1643,6 @@ namespace GameCult.Caching return null; } - /// - /// Tries to get a typed document by its CultName value. - /// - public bool TryGetByName(string name, out T? document) where T : class - { - document = GetByName(name); - return document != null; - } - /// /// Gets a typed document by an indexed value. /// @@ -1784,23 +1657,6 @@ namespace GameCult.Caching return null; } - /// - /// Tries to get a typed document by an indexed value. - /// - public bool TryGetByIndex(string alias, string value, out T? document) where T : class - { - document = GetByIndex(alias, value); - return document != null; - } - - /// - /// Resolves a typed document reference against this cache. - /// - public T? Resolve(CultRecordRef reference) where T : class - { - return Get(reference.Key); - } - /// /// Removes a document by typed handle. /// @@ -1898,8 +1754,7 @@ namespace GameCult.Caching } private IReadOnlyList<(CultStoredDocument Stored, object? Previous, bool Removed)> CommitTransaction( - CultCacheTransaction transaction, - bool soft) + CultCacheTransaction transaction) { var mutations = transaction.SnapshotForCommit() .OrderBy(pair => pair.Key, StringComparer.Ordinal) @@ -1932,7 +1787,7 @@ namespace GameCult.Caching .ToArray(); foreach (var store in _backingStores) - store.CommitBatch(upserts, deleted, soft); + store.CommitBatch(upserts, deleted); var changes = new List<(CultStoredDocument Stored, object? Previous, bool Removed)>(); lock (_stateGate) @@ -1964,8 +1819,6 @@ namespace GameCult.Caching changes.Add((stored, existing?.Document, false)); } _hasUnflushedMutations = _backingStores.Any(store => store.IsDirty); - if (!IsDirty) - LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow; } return changes; @@ -2051,7 +1904,7 @@ namespace GameCult.Caching document); } - private async Task AddStoredDocumentInternal( + private void AddStoredDocumentInternal( CultStoredDocument stored, CacheBackingStore? source, bool raiseUpdate) @@ -2086,9 +1939,6 @@ namespace GameCult.Caching { OnUpdate?.Invoke(existing?.Document, stored.Document); } - - await Task.CompletedTask; - return stored; } private void RemoveStoredDocumentInternal( @@ -2154,7 +2004,7 @@ namespace GameCult.Caching descriptor, instance), source: null, - raiseUpdate: false).GetAwaiter().GetResult(); + raiseUpdate: false); } } @@ -2301,7 +2151,6 @@ namespace GameCult.Caching public abstract class CacheBackingStore : IDisposable { private CultDocumentRegistry? _registry; - private ILogger _logger = new NullLogger(); private bool _isDirty; private CultSchemaMigrationReport[] _lastSchemaMigrationReports = Array.Empty(); @@ -2317,15 +2166,6 @@ namespace GameCult.Caching protected ConcurrentDictionary Entries { get; } = new(StringComparer.Ordinal); - /// - /// Gets or sets the backing store logger. - /// - public ILogger Logger - { - get => _logger; - set => _logger = value ?? new NullLogger(); - } - /// /// Gets whether the backing store holds staged mutations not yet durably flushed. /// @@ -2335,11 +2175,6 @@ namespace GameCult.Caching protected set => _isDirty = value; } - /// - /// Gets the UTC timestamp of the last successful durable flush. - /// - public DateTimeOffset? LastSuccessfulFlushAtUtc { get; protected set; } - /// /// Gets or sets whether disposing the backing store should flush staged mutations first. /// @@ -2350,18 +2185,9 @@ namespace GameCult.Caching /// public IReadOnlyList LastSchemaMigrationReports => _lastSchemaMigrationReports; - /// - /// Publishes documents added by the backing store. - /// - public Subject EntryAdded { get; } = new(); - /// - /// Publishes documents updated by the backing store. - /// - public Subject EntryUpdated { get; } = new(); - /// - /// Publishes documents deleted by the backing store. - /// - public Subject EntryDeleted { get; } = new(); + // Set by the cache at attach; a store calls them for records it loads or drops. + protected internal Action? Loaded; + protected internal Action? Unloaded; internal void AttachRegistry(CultDocumentRegistry registry) { @@ -2401,8 +2227,7 @@ namespace GameCult.Caching /// public virtual void CommitBatch( IReadOnlyCollection upserts, - IReadOnlyCollection deletes, - bool soft) + IReadOnlyCollection deletes) { var previousEntries = Entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); var wasDirty = IsDirty; @@ -2412,7 +2237,7 @@ namespace GameCult.Caching Delete(entry); foreach (var entry in upserts) Push(entry); - PushAll(soft); + PushAll(); } catch { @@ -2426,9 +2251,9 @@ namespace GameCult.Caching /// /// Persists all current backing store entries. /// - public abstract void PushAll(bool soft = false); + public abstract void PushAll(); /// - /// Releases backing store event subjects. + /// Flushes staged mutations first when FlushOnDispose is set. /// public virtual void Dispose() { @@ -2436,10 +2261,6 @@ namespace GameCult.Caching { PushAll(); } - - EntryAdded.Dispose(); - EntryUpdated.Dispose(); - EntryDeleted.Dispose(); } /// @@ -2487,7 +2308,6 @@ namespace GameCult.Caching protected void MarkFlushSucceeded() { IsDirty = false; - LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow; } } @@ -2571,16 +2391,13 @@ namespace GameCult.Caching continue; } Entries[stored.Key.Value] = stored; - if (existing == null) - EntryAdded.OnNext(stored); - else - EntryUpdated.OnNext(stored); + Loaded?.Invoke(stored); } foreach (var removedKey in Entries.Keys.Where(key => !persistedKeys.Contains(key)).ToArray()) { if (Entries.TryRemove(removedKey, out var removed)) - EntryDeleted.OnNext(removed); + Unloaded?.Invoke(removed); } SetLastSchemaMigrationReports(reports); @@ -2608,7 +2425,7 @@ namespace GameCult.Caching /// /// Writes the staged snapshot to disk. /// - public override void PushAll(bool soft = false) + public override void PushAll() { var snapshot = new CultPersistedStoreSnapshot { diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 6dd47f8c..08868ade 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -432,7 +432,7 @@ namespace GameCult.Mesh /// public Task FlushAsync(bool soft = false) { - return _host.FlushAsync(soft); + return _host.FlushAsync(); } /// diff --git a/src/GameCult.Networking/CultNetLocal.cs b/src/GameCult.Networking/CultNetLocal.cs index e884cd8d..87942ce8 100644 --- a/src/GameCult.Networking/CultNetLocal.cs +++ b/src/GameCult.Networking/CultNetLocal.cs @@ -100,7 +100,7 @@ namespace GameCult.Networking /// public Task FlushAsync(bool soft = false) { - return Cache.FlushAsync(soft); + return Cache.FlushAsync(); } /// diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 6624b9f2..483007cb 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -178,14 +178,11 @@ namespace GameCult.Caching.Tests Assert.That(cache.IsDirty, Is.True); Assert.That(store.IsDirty, Is.True); - Assert.That(cache.LastSuccessfulFlushAtUtc, Is.Null); cache.FlushAllBackingStores(); Assert.That(cache.IsDirty, Is.False); Assert.That(store.IsDirty, Is.False); - Assert.That(cache.LastSuccessfulFlushAtUtc, Is.Not.Null); - Assert.That(store.LastSuccessfulFlushAtUtc, Is.Not.Null); } finally { @@ -213,8 +210,7 @@ namespace GameCult.Caching.Tests cache.Dispose(); var reopened = await CultCacheMessagePack.OpenAsync(filePath); - Assert.That(reopened.TryGet(handle.Key, out NamedTestEntry? loaded), Is.True); - Assert.That(loaded!.Value, Is.EqualTo("magic")); + Assert.That(reopened.Get(handle.Key)?.Value, Is.EqualTo("magic")); } finally { @@ -310,21 +306,11 @@ namespace GameCult.Caching.Tests using var reopened = await CultCacheMessagePack.OpenAsync( filePath, - new CultCacheOpenOptions - { - Registry = registry, - UseDirectoryStore = true, - DirectoryStoreHydrationFilter = _ => false - }); + new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = true }); - Assert.That(reopened.GetGlobal(), Is.Null, - "the hydration filter owns whether the durable global becomes hot"); - Assert.That(reopened.IsDirty, Is.False, - "a cold durable global must not stage its schema default as a write"); - - var globalKey = "global:" + registry.GetRequired().SchemaId; - await reopened.PullBackingStoreRecordsAsync(metadata => metadata.Key == globalKey); Assert.That(reopened.GetGlobal()?.Value, Is.EqualTo("durable authority")); + Assert.That(reopened.IsDirty, Is.False, + "a hydrated durable global must not stage its schema default as a write"); } finally { @@ -444,63 +430,6 @@ namespace GameCult.Caching.Tests } } - [Test] - public async Task DirectoryMessagePackBackingStore_PullSelected_HydratesMatchingColdPagesOnly() - { - var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); - var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); - - try - { - CultRecordHandle hot; - CultRecordHandle requested; - CultRecordHandle unrelated; - using (var seed = await CultCacheMessagePack.OpenAsync( - filePath, - new CultCacheOpenOptions { UseDirectoryStore = true })) - { - hot = await seed.UpsertAsync(new NamedTestEntry { Name = "hot", Value = "already-loaded" }); - requested = await seed.UpsertAsync(new NamedTestEntry { Name = "requested", Value = "load-later" }); - unrelated = await seed.UpsertAsync(new NamedTestEntry - { - Name = "unrelated", - Value = new string('u', 1024 * 1024) - }); - await seed.FlushAsync(); - } - - string PageFor(CultRecordHandle handle) => - Directory.GetFiles(recordsPath, "*.msgpack").Single(path => string.Equals( - CultDocumentMessagePackSerialization.DeserializePersistedRecord(File.ReadAllBytes(path)).Key, - handle.Key.Value, - StringComparison.Ordinal)); - - using var selected = await CultCacheMessagePack.OpenAsync( - filePath, - new CultCacheOpenOptions - { - UseDirectoryStore = true, - DirectoryStoreHydrationFilter = metadata => metadata.Key == hot.Key.Value - }); - Assert.That(selected.Get(requested.Key), Is.Null); - - using (new FileStream(PageFor(unrelated), FileMode.Open, FileAccess.Read, FileShare.None)) - { - await selected.PullBackingStoreRecordsAsync(metadata => metadata.Key == requested.Key.Value); - Assert.That(selected.Get(requested.Key)?.Value, Is.EqualTo("load-later")); - Assert.That(selected.Get(hot.Key)?.Value, Is.EqualTo("already-loaded")); - Assert.That(selected.Get(unrelated.Key), Is.Null); - } - } - finally - { - if (File.Exists(filePath)) - File.Delete(filePath); - if (Directory.Exists(recordsPath)) - Directory.Delete(recordsPath, recursive: true); - } - } - [Test] public async Task DirectoryMessagePackBackingStore_Loads_Record_When_Manifest_Misses_Catalog_Entry() { @@ -876,7 +805,7 @@ namespace GameCult.Caching.Tests var readerCache = new CultCache(); readerCache.AddBackingStore(readerStore); var observerRan = false; - using var subscription = readerStore.EntryAdded.Subscribe(_ => + using var subscription = readerCache.Watch().Subscribe(_ => { if (observerRan) return; @@ -1059,7 +988,7 @@ namespace GameCult.Caching.Tests } [Test] - public async Task CultCache_TypedStoredDocumentIndex_Tracks_Upsert_Replacement_And_Removal() + public async Task CultCache_GetAll_Tracks_Upsert_Replacement_And_Removal() { var cache = new CultCache(); var key = new CultRecordKey("typed-index:named"); @@ -1073,20 +1002,20 @@ namespace GameCult.Caching.Tests Name = "unrelated" }, new CultRecordKey("typed-index:unrelated")); - var initial = cache.GetStoredDocuments().Single(); - Assert.That(initial.Key, Is.EqualTo(key)); - Assert.That(((NamedTestEntry)initial.Document).Value, Is.EqualTo("first")); + var initial = cache.GetAll().Single(); + Assert.That(cache.TryGetHandle(initial)?.Key, Is.EqualTo(key)); + Assert.That(initial.Value, Is.EqualTo("first")); await cache.UpsertAsync(typeof(NamedTestEntry), new NamedTestEntry { Name = "indexed", Value = "second" }, key); - var replaced = cache.GetStoredDocuments().Single(); - Assert.That(((NamedTestEntry)replaced.Document).Value, Is.EqualTo("second")); + var replaced = cache.GetAll().Single(); + Assert.That(replaced.Value, Is.EqualTo("second")); Assert.That(cache.Remove(key), Is.True); - Assert.That(cache.GetStoredDocuments(), Is.Empty); + Assert.That(cache.GetAll(), Is.Empty); } [Test]