C# write set: one re-mint for every store kind, and a foreign refusal carries its reload failure

The directory store wrote an earlier storedAt over a later record it replaced, against the contract.
CacheBackingStore.StoredAtOver is now the one re-mint; the single-file apply and the directory
generation write both call it. A foreign refusal passes the reload's cause to
CultSchemaConflictException as its inner exception. Per-commit build not verified.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 06:05:41 +02:00
commit 2e3312635d
3 changed files with 36 additions and 11 deletions

View file

@ -262,6 +262,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
{
Directory.CreateDirectory(_recordDirectory.FullName);
var currentIndex = currentManifest.Records.ToDictionary(record => record.Key, record => record, StringComparer.Ordinal);
var replaced = currentManifest.Records.ToDictionary(record => record.Key, record => record.StoredAt, StringComparer.Ordinal);
foreach (var key in _deletedKeys.Keys)
currentIndex.Remove(key);
foreach (var key in _dirtyKeys.Keys)
@ -280,6 +281,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
continue;
}
// A record this write replaces is stored later than it: the one re-mint every store kind shares (StoredAtOver).
if (replaced.TryGetValue(key, out var replacedAt))
stored.StoredAt = StoredAtOver(stored.StoredAt, replacedAt);
var record = ToPersistedRecord(stored, document =>
CultDocumentMessagePackSerialization.SerializeUntyped(
document,

View file

@ -3634,7 +3634,7 @@ namespace GameCult.Caching
.ToArray();
if (refused.Length == 0)
return;
Refuse(refused.Select(record => record.Key), _ => null, unstage, _ => Overwrites(refused));
Refuse(refused.Select(record => record.Key), _ => null, unstage, inner => Overwrites(inner, refused));
}
/// <summary>
@ -3688,13 +3688,30 @@ namespace GameCult.Caching
string.Equals(schemaId, otherSchemaId, StringComparison.Ordinal) &&
string.Equals(storedAt, otherStoredAt, StringComparison.Ordinal);
protected CultSchemaConflictException Overwrites(params CultForeignRecord[] foreign) => new(
// The one re-mint, for every store kind (storedat-on-change): a record written over one the store holds is stored at a storedAt
// later than the one it replaces, whoever wrote that. The staged storedAt stands only when it is already later (both parsed
// RoundtripKind; an unparsable one counts as not later); otherwise it is minted from the replaced one.
protected static string StoredAtOver(string storedAt, string replacedAt) =>
StoredLater(storedAt, replacedAt) ? storedAt : CultCache.MintStoredAt(replacedAt);
private static bool StoredLater(string storedAt, string than) =>
DateTimeOffset.TryParse(storedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var later) &&
DateTimeOffset.TryParse(than, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var earlier) &&
later > earlier;
// A record this store carries for a schema it does not know: a write that would replace or remove it is refused. When the
// reload that refusal owes failed, the refusal carries that failure as its inner exception.
protected CultSchemaConflictException Overwrites(params CultForeignRecord[] foreign) => Overwrites(null, foreign);
protected CultSchemaConflictException Overwrites(Exception? inner, CultForeignRecord[] foreign) => new(
$"{(foreign.Length == 1 ? "Record" : "Records")} {string.Join(", ", foreign.Select(record => $"'{record.Key}' (schema id '{record.SchemaId}', '{record.SchemaName}')"))} in {this} " +
"no registered type owns or lists as compatible. The store carries what it holds under such a schema untouched and refuses a write that would replace or remove it; " +
"declare the id on a type to claim it.",
"declare the id on a type to claim it." +
(inner == null ? string.Empty : " Reloading what the write refused failed, so the cache is unchanged."),
foreign[0].SchemaId,
foreign.Select(record => record.SchemaName).Distinct(StringComparer.Ordinal).ToArray(),
foreign[0].Key);
foreign[0].Key,
inner);
protected void MarkFlushSucceeded()
{
@ -3924,8 +3941,8 @@ namespace GameCult.Caching
var replaced = (durable?.Records ?? Array.Empty<CultPersistedRecord>()).ToDictionary(record => record.Key, record => record.StoredAt, StringComparer.Ordinal);
foreach (var staged in upserts)
{
if (replaced.TryGetValue(staged.Record.Key, out var durableAt) && !StoredLater(staged.Record.StoredAt, durableAt))
staged.Record.StoredAt = CultCache.MintStoredAt(durableAt);
if (replaced.TryGetValue(staged.Record.Key, out var durableAt))
staged.Record.StoredAt = StoredAtOver(staged.Record.StoredAt, durableAt);
}
var written = upserts.Select(staged => staged.Record.Key).ToHashSet(StringComparer.Ordinal);
@ -3963,11 +3980,6 @@ namespace GameCult.Caching
return upserts;
}
private static bool StoredLater(string storedAt, string than) =>
DateTimeOffset.TryParse(storedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var later) &&
DateTimeOffset.TryParse(than, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var earlier) &&
later > earlier;
private void RefuseWriteIntoMovedVariantStore(CultPersistedStoreSnapshot? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals)
{
var records = durable?.Records ?? Array.Empty<CultPersistedRecord>();

View file

@ -18,6 +18,15 @@ namespace GameCult.Caching
RecordKey = recordKey;
}
/// <summary>The same refusal when the reload it owes (what the store holds now, loaded into the cache) itself failed: that failure is the inner exception.</summary>
public CultSchemaConflictException(string message, string schemaId, string[] schemaNames, string recordKey, Exception? innerException)
: base(message, innerException)
{
SchemaId = schemaId;
SchemaNames = schemaNames;
RecordKey = recordKey;
}
public string SchemaId { get; }
public string[] SchemaNames { get; }
public string RecordKey { get; }