diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 71140019..ad77caa8 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -182,10 +182,17 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore return CultCommitOutcome.Contended; var (manifest, catalogBytes) = ReadManifest(); if (request.HasConditions && !request.ConditionsHold( - manifest.Records.ToDictionary(record => record.Key, record => VersionOf(record.Payload), StringComparer.Ordinal), Entries.Values)) + manifest.Records.ToDictionary(record => record.Key, record => VersionOf(record.Payload), StringComparer.Ordinal), Entries.Values, ForeignRecords)) return CultCommitOutcome.Mismatch; - RefuseUndeclaredOnDisk(manifest, request.Upserts.Concat(request.Deletes).Select(entry => entry.Key.Value).Concat(StagedKeys)); + RefuseUndeclaredOnDisk(manifest, request.Upserts.Concat(request.Deletes).Select(entry => entry.Key.Value).Concat(request.Checks).Concat(StagedKeys)); + if (request.Upserts.Count == 0 && request.Deletes.Count == 0 && !IsDirty) + { + // Nothing to write: the batch only judged keys it does not hold. + MarkFlushSucceeded(); + return CultCommitOutcome.Committed; + } + var previousEntries = Entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); var previousDirtyKeys = _dirtyKeys.Keys.ToArray(); var previousDeletedKeys = _deletedKeys.Keys.ToArray(); @@ -230,23 +237,41 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore ThrowIfReadOnly(); Held(() => { - if (!IsDirty) + if (!IsDirty && StagedChecks.Count == 0) return; Directory.CreateDirectory(_manifestFile.DirectoryName!); using var commitLease = AcquireCommitLease(wait: true, create: true); var (manifest, catalogBytes) = ReadManifest(); RefuseUndeclaredOnDisk(manifest, StagedKeys); - WriteGeneration(manifest, catalogBytes); + if (IsDirty) + WriteGeneration(manifest, catalogBytes); + else + MarkFlushSucceeded(); }); } - private IEnumerable StagedKeys => _deletedKeys.Keys.Concat(_dirtyKeys.Keys); + // Every key the next flush or commit judges: the staged writes and removals, and the staged checks. + private IEnumerable StagedKeys => _deletedKeys.Keys.Concat(_dirtyKeys.Keys).Concat(StagedChecks); - // The manifest as it is now, read without the commit lease: nothing is written after the read, the manifest is only ever - // replaced whole, and a read-only store must not create the lease. - protected internal override void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) => - Held(() => RefuseUndeclaredOnDisk(ReadManifest().Snapshot, keys)); + // A writable store judges a removal of a key its cache does not hold at its next flush or commit, under the commit lease + // (StageChecks); a read-only store has neither, so it answers from its view and never creates the lease. + protected internal override void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) + { + if (IsReadOnly) + base.RefuseRemovalOfUndeclared(keys); + else + StageChecks(keys); + } + + // A manifest record no registered type claims, listed at the version its index record names. + private CultForeignRecord? ForeignAt(CultPersistedRecord record, IReadOnlyCollection catalog) + { + if (Foreign(record, catalog) is not { } carried) + return null; + carried.StoredVersion = VersionOf(record.Payload); + return carried; + } // The manifest's other records stay as they are, foreign and read-only ones included; a write that would replace or remove a // record the manifest holds under an id no registered type declares is refused before any page is written, by the one rule every @@ -261,7 +286,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore key => HeldAt(key) is { } held && Foreign(held, currentManifest.SchemaCatalog) == null ? LoadPage(held, currentManifest.SchemaCatalog, out _, out _) : null, - key => HeldAt(key) is { } held ? Foreign(held, currentManifest.SchemaCatalog) : null, + key => HeldAt(key) is { } held ? ForeignAt(held, currentManifest.SchemaCatalog) : null, key => { _dirtyKeys.TryRemove(key, out _); @@ -365,7 +390,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var claimed = new List(records.Length); foreach (var metadata in records) { - if (Foreign(metadata, catalogEntries) is { } carried) + if (ForeignAt(metadata, catalogEntries) is { } carried) foreign.Add(carried); else claimed.Add(metadata); diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 2470381c..ede033dc 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -179,6 +179,10 @@ namespace GameCult.Caching public string SchemaName { get; } public string StoredAt { get; } + + // The version of the record's stored bytes when its store read it, in that store's spelling; null when the store kind + // did not give one. ExpectUnchanged compares it with the version on disk at the commit. + internal string? StoredVersion { get; set; } } public sealed class CultPersistedStoreSnapshot @@ -1860,24 +1864,40 @@ namespace GameCult.Caching internal CultCommitRequest( IReadOnlyList upserts, IReadOnlyList deletes, + IReadOnlyList checks, IReadOnlyList<(CultRecordKey Key, string? Version)> expected, bool expectUnchanged) { Upserts = upserts; Deletes = deletes; + Checks = checks; Expected = expected; ExpectUnchanged = expectUnchanged; } public IReadOnlyList Upserts { get; } public IReadOnlyList Deletes { get; } + + /// + /// Keys the batch removes that its cache does not hold. Such a removal removes nothing, so it is never in + /// ; but a store that holds a record at one of these keys under an id no registered type declares + /// refuses the commit, as it refuses a write of that record. A judges these keys + /// under its lock, with the keys it writes, and never removes them: a record a declared type reads stays where it is. + /// + public IReadOnlyList Checks { get; } public IReadOnlyList<(CultRecordKey Key, string? Version)> Expected { get; } public bool ExpectUnchanged { get; } public bool HasConditions => Expected.Count > 0 || ExpectUnchanged; - // durable is the version of each record the store holds on disk now, by key; observed is what its cache last loaded or - // committed. Identity is the record's version, the SHA-256 of its stored bytes, which names its schema id and storedAt too. - public bool ConditionsHold(IReadOnlyDictionary durable, IEnumerable observed) + /// + /// Whether the batch's conditions hold. is the version of each record the store holds on disk now, + /// by key; is what the store's cache last loaded or committed, and the + /// foreign records the store carries as it last read them (; empty for a home + /// with none). Under each foreign record compares against the version it had when the store + /// read it, as a record the cache holds compares against its own. Identity is the record's version, the SHA-256 of its stored + /// bytes, which names its schema id and storedAt too. + /// + public bool ConditionsHold(IReadOnlyDictionary durable, IEnumerable observed, IEnumerable foreign) { foreach (var (key, version) in Expected) { @@ -1893,6 +1913,7 @@ namespace GameCult.Caching .OrderBy(pair => pair.Key, StringComparer.Ordinal) .SequenceEqual(observed .Select(stored => (Key: stored.Key.Value, Version: stored.StoredVersion)) + .Concat(foreign.Select(record => (record.Key, Version: record.StoredVersion))) .OrderBy(pair => pair.Key, StringComparer.Ordinal)); } } @@ -2291,7 +2312,7 @@ namespace GameCult.Caching { foreach (var (store, _) in _stores) { - if (!store.IsReadOnly && store.IsDirty) + if (!store.IsReadOnly && (store.IsDirty || store.HasStagedChecks)) store.PushAll(); } return true; @@ -2352,8 +2373,15 @@ namespace GameCult.Caching return key; })); - // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares, - // read-only or foreign. False when the cache holds nothing there and no store carries a foreign record there. + /// + /// Removes the record at ; false when the cache holds nothing there. + /// + /// + /// A record stored under an id no registered type declares, read-only or foreign, is never removed: removing one the cache + /// holds is refused with now. A key the cache does not hold costs nothing here and + /// returns false; each writable store judges it at its next flush or commit, under its lock, and refuses that flush or + /// commit if it holds a record there under such an id (a read-only store answers now, from what it last loaded). + /// public bool Remove(CultRecordKey key) => Held(() => { if (!_entries.TryGetValue(key.Value, out var existing)) @@ -2467,7 +2495,7 @@ namespace GameCult.Caching { Held(() => { - if (FlushAttachedStoresOnDispose && IsDirty) + if (FlushAttachedStoresOnDispose && (IsDirty || _stores.Any(entry => entry.Store.HasStagedChecks))) FlushAllBackingStores(); return true; }); @@ -2631,18 +2659,31 @@ namespace GameCult.Caching : op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant) : StampFlatten(op.Key)) .ToArray(); - RefuseRemovalOfUndeclared(batch.Operations.Values + // A removal of a key the cache does not hold removes nothing: it is a check the commit's store judges under its lock. + var checks = batch.Operations.Values .Where(op => op.IsRemoval && !_entries.ContainsKey(op.Key.Value)) .Select(op => op.Key.Value) - .ToArray()); + .ToArray(); var deletes = batch.Operations.Values .Where(op => op.IsRemoval && _entries.ContainsKey(op.Key.Value)) .Select(op => _entries[op.Key.Value]) .ToArray(); return Admit(upserts, deletes, null, (home, resolved) => { + // A batch that only checks keys names no home; it commits through the cache's one writable store, if it has one. + if (home == null && checks.Length > 0 && _stores.Where(entry => !entry.Store.IsReadOnly).Take(2).ToArray() is { Length: 1 } only) + home = only[0].Store; + // Every other store answers the checks now, from what it last loaded: the batch commits through one store. + foreach (var (store, _) in _stores) + { + if (store == home) + continue; + foreach (var key in checks) + store.RefuseUndeclared(key); + } + // What the store holds is what the hold resolved: a variant's delta beside its resolved view. - var request = new CultCommitRequest(resolved, deletes, batch.Expected.ToArray(), batch.ExpectsUnchanged); + var request = new CultCommitRequest(resolved, deletes, checks, batch.Expected.ToArray(), batch.ExpectsUnchanged); if (home != null) { if (request.HasConditions && home.IsDirty) @@ -2653,14 +2694,14 @@ namespace GameCult.Caching if (request.HasConditions && _stores.Count > 0) throw new InvalidOperationException("A conditional batch names its home store through a record it upserts or removes."); var inMemory = _entries.Values.ToDictionary(entry => entry.Key.Value, entry => entry.StoredVersion, StringComparer.Ordinal); - return request.ConditionsHold(inMemory, _entries.Values) ? CultCommitOutcome.Committed : CultCommitOutcome.Mismatch; + return request.ConditionsHold(inMemory, _entries.Values, Array.Empty()) ? CultCommitOutcome.Committed : CultCommitOutcome.Mismatch; }); }); } // A removal of a key the cache does not hold removes nothing, so no write carries it to a store; but a store that holds a // record there under an id no registered type declares refuses it, as it refuses every write of that record, rather than let - // the caller believe it was removed. + // the caller believe it was removed. Each store judges it its own way (CacheBackingStore.RefuseRemovalOfUndeclared). private void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) { if (keys.Count == 0) @@ -3529,13 +3570,21 @@ namespace GameCult.Caching public abstract void Delete(CultStoredDocument entry); public abstract void PushAll(); - // One durable step under the store's exclusive lock; conditions are evaluated against what is on disk then. - // A failure restores the store's staged view. + /// + /// One durable step under the store's exclusive lock: the batch and every staged write land onto the store as it is then, + /// and the batch's conditions are evaluated against it. A failure restores the store's staged view. + /// + /// + /// An implementation judges , and its own , under that + /// lock with the keys it writes: a key whose record is under an id no registered type declares refuses the commit, and a + /// checked key is never removed. Pass to + /// . + /// public abstract CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait); public virtual void Dispose() { - if (FlushOnDispose && IsDirty && !IsReadOnly) + if (FlushOnDispose && (IsDirty || HasStagedChecks) && !IsReadOnly) { PushAll(); } @@ -3654,16 +3703,52 @@ namespace GameCult.Caching throw Overwrites(null, new[] { (key, held.StoredSchemaId, held.Descriptor.SchemaName) }); } - // The apply's refusal for removals of keys the cache does not hold. Such a removal writes nothing, so no write carries it to - // where the store is read; this reads it instead. A key whose durable record is under an id no registered type declares is - // refused and reloaded as the apply refuses and reloads it, including a record another writer put there after the load. - // A store kind that cannot read its durable state answers from its view (RefuseUndeclared). + /// + /// Called when the cache removes keys it does not hold, outside a batch that commits through this store. Such a removal + /// removes nothing, but a store that holds a record at one of these keys under an id no registered type declares must + /// refuse it rather than let the caller believe the record is gone. + /// + /// + /// + /// The default refuses now, from this store's view: the foreign records its last load listed and the records it holds + /// (). It reads nothing, so it cannot see a record another writer put there after that load. + /// + /// + /// A writable store kind that reads its durable state under a lock overrides this to call , and + /// judges at its next or as it judges + /// : under the lock, with the keys it writes, refused through the same refusal, never + /// removed. It calls when that step succeeds, which clears them. A read-only store has no + /// such step and keeps the default. Both shipped store kinds do this. + /// + /// protected internal virtual void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) { foreach (var key in keys) RefuseUndeclared(key); } + // Keys a removal named that this store's cache does not hold, waiting for this store's next flush or commit to judge them. + // A refusal takes back the keys it refused; a successful flush or commit clears them. + private readonly HashSet _checks = new(StringComparer.Ordinal); + + /// + /// The keys staged for this store's next flush or commit to judge. They are not writes: + /// does not count them. + /// + protected IReadOnlyCollection StagedChecks => _checks; + + /// + /// Stages keys for this store's next flush or commit to judge without removing them. See + /// . + /// + protected void StageChecks(IEnumerable keys) => Held(() => + { + foreach (var key in keys) + _checks.Add(key); + }); + + internal bool HasStagedChecks => _checks.Count > 0; + // A stored record under an id the writer does not declare, named by the catalog entry that publishes the id. Header only: no // payload is decoded. Null for a record under a declared id. protected static (string Key, string SchemaId, string SchemaName)? Undeclared( @@ -3739,7 +3824,10 @@ namespace GameCult.Caching } foreach (var key in refused) + { unstage(key); + _checks.Remove(key); + } foreach (var held in dropped) Entries.TryRemove(held.Key.Value, out _); foreach (var stored in loaded) @@ -3790,9 +3878,11 @@ namespace GameCult.Caching refused[0].Key, inner); + // A flush or commit succeeded: nothing staged is left, writes or checks. protected void MarkFlushSucceeded() { IsDirty = false; + _checks.Clear(); } } @@ -3917,6 +4007,7 @@ namespace GameCult.Caching var version = versions[record.Key] = durable.Version(record.Key); if (Foreign(record, snapshot.SchemaCatalog) is { } carried) { + carried.StoredVersion = version; foreign.Add(carried); continue; } @@ -3981,7 +4072,7 @@ namespace GameCult.Caching { var disk = ReadSnapshot(); var (upserts, removals) = StagedWrites(Array.Empty(), Array.Empty()); - Wrote(ApplyWriteSet(disk, upserts, removals)); + Wrote(ApplyWriteSet(disk, upserts, removals, Array.Empty())); } _staged.Clear(); @@ -4006,11 +4097,12 @@ namespace GameCult.Caching var disk = ReadSnapshot(); if (request.HasConditions && !request.ConditionsHold( (disk?.Snapshot.Records ?? Array.Empty()).ToDictionary(record => record.Key, record => disk!.Version(record.Key), StringComparer.Ordinal), - Entries.Values)) + Entries.Values, + ForeignRecords)) return CultCommitOutcome.Mismatch; var (upserts, removals) = StagedWrites(request.Upserts, request.Deletes); - var written = ApplyWriteSet(disk, upserts, removals); + var written = ApplyWriteSet(disk, upserts, removals, request.Checks); foreach (var entry in request.Deletes) Entries.TryRemove(entry.Key.Value, out _); foreach (var entry in request.Upserts) @@ -4067,20 +4159,24 @@ namespace GameCult.Caching internal void ApplyWriteSet(IReadOnlyCollection upserts, IReadOnlyCollection removals) => Held(() => { using (AcquireLock(wait: true)) - _ = ApplyWriteSet(ReadSnapshot(), upserts, removals); + _ = ApplyWriteSet(ReadSnapshot(), upserts, removals, Array.Empty()); }); // Flush, commit and Mesh's write all end here, with the durable store they read under the lock they hold. First, a key the write - // names whose durable record is under an id the writer does not declare refuses the write (RefuseUndeclaredOnDisk). Then a store that holds + // names or checks whose durable record is under an id the writer does not declare refuses the write (RefuseUndeclaredOnDisk); a + // write that then has nothing to write (it only checked keys) writes no file. Then a store that holds // a variant, or would after this write, is written only while its record versions are what this store last read: a variant // resolves against other records, and a write decodes none it did not stage, so it cannot judge another writer's. Versions // only: a write hashes other records' bytes, never decodes them. Otherwise the whole write is refused and what it refused is // reloaded (Refuse). Every record the writer staged is encoded from its object now, and its version is the hash of that // encoding; every other record is copied as the slice the file holds. Returns the records written, each with the version of // the bytes written. - private IReadOnlyCollection<(CultStagedRecord Staged, string Version)> ApplyWriteSet(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals) + private IReadOnlyCollection<(CultStagedRecord Staged, string Version)> ApplyWriteSet( + Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals, IEnumerable checks) { - RefuseUndeclaredOnDisk(durable, upserts, removals); + RefuseUndeclaredOnDisk(durable, upserts, removals, checks); + if (upserts.Count == 0 && removals.Count == 0) + return Array.Empty<(CultStagedRecord, string)>(); RefuseWriteIntoMovedVariantStore(durable, upserts, removals); var durableRecords = durable?.Snapshot.Records ?? Array.Empty(); // A record written over one the store holds is stored at a storedAt later than the one it replaces, whoever wrote that. @@ -4180,17 +4276,22 @@ namespace GameCult.Caching inner)); } - // The file as it is now, read as a pull reads it, without the lock: nothing is written after the read, and the file is only - // ever replaced whole. - protected internal override void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) => - Held(() => RefuseUndeclaredOnDisk(ReadSnapshot(), keys, Registry.Declares)); + // A writable store judges a removal of a key its cache does not hold at its next flush or commit, under the lock (StageChecks); + // a read-only store has neither, so it answers from its view and never creates the lock file. + protected internal override void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) + { + if (IsReadOnly) + base.RefuseRemovalOfUndeclared(keys); + else + StageChecks(keys); + } // A cache declares through its registry. A cacheless CultMesh write declares the ids of the catalog entry it writes: its own id - // and the ids it lists compatible. - private void RefuseUndeclaredOnDisk(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals) => + // and the ids it lists compatible. The keys judged are every key staged, written, removed or checked. + private void RefuseUndeclaredOnDisk(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals, IEnumerable checks) => RefuseUndeclaredOnDisk( durable, - _staged.Concat(upserts.Select(staged => staged.Record.Key)).Concat(removals), + _staged.Concat(upserts.Select(staged => staged.Record.Key)).Concat(removals).Concat(checks).Concat(StagedChecks), Cache != null ? Registry.Declares : id => upserts.Any(staged => @@ -4231,10 +4332,14 @@ namespace GameCult.Caching } // The durable record at a key as a load lists it when no registered type claims its id; null otherwise. - private CultForeignRecord? ForeignAt(Durable? durable, string key) => - durable?.Snapshot.Records.FirstOrDefault(candidate => string.Equals(candidate.Key, key, StringComparison.Ordinal)) is { } record - ? Foreign(record, durable.Snapshot.SchemaCatalog) - : null; + private CultForeignRecord? ForeignAt(Durable? durable, string key) + { + if (durable?.Snapshot.Records.FirstOrDefault(candidate => string.Equals(candidate.Key, key, StringComparison.Ordinal)) is not { } record || + Foreign(record, durable.Snapshot.SchemaCatalog) is not { } carried) + return null; + carried.StoredVersion = durable.Version(key); + return carried; + } // The durable record at a key decoded as a load decodes it, at its durable version: null when the store holds none there or // holds it foreign. The one decode both refusals reload through.