CultCache.Update<T>: conditional read-apply-commit with bounded retry

Reads the held content version, applies the caller's function to a codec
copy, commits with Expected under the store lock, and re-runs on a lost
race. CultCodec.Clone is the one copy, shared with CultInspectorModel.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-13 14:08:31 +00:00
commit 2833d25671
3 changed files with 84 additions and 1 deletions

View file

@ -2052,6 +2052,29 @@ namespace GameCult.Caching
Contended
}
public enum CultUpdateOutcome
{
Updated,
Declined,
LostRace
}
/// <summary>
/// What <see cref="CultCache.Update{T}"/> did. A lost race is a value, not an exception. <see cref="Document"/> is the instance the
/// cache holds at the key when Update returns: the written one for Updated, null when the key is absent.
/// </summary>
public readonly struct CultUpdateResult<T> where T : class
{
public CultUpdateResult(CultUpdateOutcome outcome, T? document)
{
Outcome = outcome;
Document = document;
}
public CultUpdateOutcome Outcome { get; }
public T? Document { get; }
}
public sealed class CultCommitRequest
{
internal CultCommitRequest(
@ -2563,6 +2586,7 @@ namespace GameCult.Caching
return UpsertAsync(document, handle);
}
// A blind write, last-writer-wins per key; Update is the conditional golden path.
// Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares.
public Task<CultRecordHandle<T>> UpsertAsync<T>(T document, CultRecordHandle<T>? handle = null)
{
@ -2639,6 +2663,7 @@ namespace GameCult.Caching
/// <summary>
/// Commits the staged batch. Returns false when a condition failed: nothing was written, changed in memory, or published.
/// Without <c>Expect</c> a commit is blind; <see cref="Update{T}"/> retries a conditional one.
/// </summary>
/// <remarks>
/// A commit that succeeds and then fails in a journal or observer still stands and is published, and the writer
@ -2656,6 +2681,59 @@ namespace GameCult.Caching
return Land(stage, wait: false);
}
/// <summary>
/// Reads the record at <paramref name="key"/>, applies <paramref name="apply"/> to a private copy of it, and commits the result only
/// if the record is still the one that was read; when another writer got there first it re-reads and runs <paramref name="apply"/>
/// again, up to <paramref name="maxAttempts"/> times. The write is durable when Update returns.
/// </summary>
/// <remarks>
/// <paramref name="apply"/> may run up to <paramref name="maxAttempts"/> times. It gets a private copy (null when the key holds
/// nothing), which it may change and return. It must not write to the cache or have other side effects. Return null to write
/// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. The cache
/// needs a codec to copy with. Running out of attempts returns <see cref="CultUpdateOutcome.LostRace"/>; every other failure throws.
/// </remarks>
public CultUpdateResult<T> Update<T>(CultRecordKey key, Func<T?, T?> apply, int maxAttempts = 5) where T : class
{
if (apply == null) throw new ArgumentNullException(nameof(apply));
if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "At least one attempt is required.");
var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create.");
for (var attempt = 1; ; attempt++)
{
var (version, copy) = Held(() =>
{
if (!_entries.TryGetValue(key.Value, out var held))
return ((string?)null, (T?)null);
if (held.Variant != null || held.Document is not T)
throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it.");
return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType));
});
var next = apply(copy);
if (next == null)
return new CultUpdateResult<T>(CultUpdateOutcome.Declined, Get<T>(key));
CultCommitOutcome outcome;
try
{
outcome = Land(batch =>
{
batch.Expected.Add((key, version));
batch.Upsert(next, new CultRecordHandle<T>(key));
}, wait: true);
}
catch (CultWriteConflictException conflict) when (conflict.InnerException == null)
{
outcome = CultCommitOutcome.Mismatch;
}
if (outcome == CultCommitOutcome.Committed)
return new CultUpdateResult<T>(CultUpdateOutcome.Updated, next);
if (outcome != CultCommitOutcome.Mismatch)
throw new InvalidOperationException($"Update of {key.Value} was refused: {outcome}.");
if (attempt >= maxAttempts)
return new CultUpdateResult<T>(CultUpdateOutcome.LostRace, Get<T>(key));
Held(() => { Home(next.GetType())?.PullAll(); return true; });
}
}
public CultRecordHandle<T>? TryGetHandle<T>(T document)
{
if (document == null) throw new ArgumentNullException(nameof(document));

View file

@ -352,7 +352,7 @@ namespace GameCult.Caching
}
}
public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type, type));
public object Clone(object document, Type type) => _codec.Clone(document, type);
public CultInspectorEdit BeginEdit(CultStoredDocument record)
{

View file

@ -29,6 +29,11 @@ namespace GameCult.Caching
// (document payload, slot and encoded value pairs)
public Func<byte[], IReadOnlyList<KeyValuePair<int, byte[]>>, byte[]> Overlay { get; }
// The one copy of a document: its encoding, decoded again. Update's private argument and CultInspectorModel.Clone share it.
internal object Clone(object document, Type type) =>
Deserialize(type, type, Serialize(document, type, type))
?? throw new InvalidOperationException($"The codec returned null for {type.FullName}.");
/// <summary>
/// Decodes a document payload read from a store file, as a cache load decodes it: the ids a load mints are minted from the key
/// and their path, so every reader of one store mints the same ids, and a payload a cache write would refuse is refused here too.