diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index 469fc7af..a50b3e03 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -2052,6 +2052,29 @@ namespace GameCult.Caching
Contended
}
+ public enum CultUpdateOutcome
+ {
+ Updated,
+ Declined,
+ LostRace
+ }
+
+ ///
+ /// What did. A lost race is a value, not an exception. is the instance the
+ /// cache holds at the key when Update returns: the written one for Updated, null when the key is absent.
+ ///
+ public readonly struct CultUpdateResult 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> UpsertAsync(T document, CultRecordHandle? handle = null)
{
@@ -2639,6 +2663,7 @@ namespace GameCult.Caching
///
/// Commits the staged batch. Returns false when a condition failed: nothing was written, changed in memory, or published.
+ /// Without Expect a commit is blind; retries a conditional one.
///
///
/// 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);
}
+ ///
+ /// Reads the record at , applies 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
+ /// again, up to times. The write is durable when Update returns.
+ ///
+ ///
+ /// may run up to 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 ; every other failure throws.
+ ///
+ public CultUpdateResult Update(CultRecordKey key, Func 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(CultUpdateOutcome.Declined, Get(key));
+ CultCommitOutcome outcome;
+ try
+ {
+ outcome = Land(batch =>
+ {
+ batch.Expected.Add((key, version));
+ batch.Upsert(next, new CultRecordHandle(key));
+ }, wait: true);
+ }
+ catch (CultWriteConflictException conflict) when (conflict.InnerException == null)
+ {
+ outcome = CultCommitOutcome.Mismatch;
+ }
+
+ if (outcome == CultCommitOutcome.Committed)
+ return new CultUpdateResult(CultUpdateOutcome.Updated, next);
+ if (outcome != CultCommitOutcome.Mismatch)
+ throw new InvalidOperationException($"Update of {key.Value} was refused: {outcome}.");
+ if (attempt >= maxAttempts)
+ return new CultUpdateResult(CultUpdateOutcome.LostRace, Get(key));
+ Held(() => { Home(next.GetType())?.PullAll(); return true; });
+ }
+ }
+
public CultRecordHandle? TryGetHandle(T document)
{
if (document == null) throw new ArgumentNullException(nameof(document));
diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs
index 7aa6ab74..60dec974 100644
--- a/src/GameCult.Caching/CultInspectorModel.cs
+++ b/src/GameCult.Caching/CultInspectorModel.cs
@@ -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)
{
diff --git a/src/GameCult.Caching/CultVariants.cs b/src/GameCult.Caching/CultVariants.cs
index 48909c19..e5c28bbf 100644
--- a/src/GameCult.Caching/CultVariants.cs
+++ b/src/GameCult.Caching/CultVariants.cs
@@ -29,6 +29,11 @@ namespace GameCult.Caching
// (document payload, slot and encoded value pairs)
public Func>, 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}.");
+
///
/// 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.