Harden CultLib for local production use

This commit is contained in:
Meta Vens 2026-05-09 11:45:46 +01:00
commit 3f1cb8b36e
12 changed files with 1376 additions and 47 deletions

View file

@ -40,7 +40,8 @@ public sealed class CultDocumentResolver : IFormatterResolver
public static class CultDocumentMessagePackSerialization
{
private const int PersistedRecordFieldCount = 4;
private const int SchemaCatalogEntryFieldCount = 6;
private const int SchemaCatalogEntryFieldCount = 7;
private const int SchemaCatalogMemberFieldCount = 8;
private const int StoreSnapshotFieldCount = 3;
/// <summary>
@ -277,6 +278,12 @@ public static class CultDocumentMessagePackSerialization
{
writer.Write(schemaId);
}
writer.WriteArrayHeader(entry.Members.Length);
foreach (var member in entry.Members)
{
WriteSchemaCatalogMember(ref writer, member);
}
}
private static CultSchemaCatalogEntry ReadSchemaCatalogEntry(ref MessagePackReader reader)
@ -319,6 +326,16 @@ public static class CultDocumentMessagePackSerialization
}
}
if (fieldCount > 6)
{
var memberCount = reader.ReadArrayHeader();
entry.Members = new CultSchemaMemberCatalogEntry[memberCount];
for (var index = 0; index < memberCount; index++)
{
entry.Members[index] = ReadSchemaCatalogMember(ref reader);
}
}
for (var index = SchemaCatalogEntryFieldCount; index < fieldCount; index++)
{
reader.Skip();
@ -326,6 +343,72 @@ public static class CultDocumentMessagePackSerialization
return entry;
}
private static void WriteSchemaCatalogMember(ref MessagePackWriter writer, CultSchemaMemberCatalogEntry member)
{
writer.WriteArrayHeader(SchemaCatalogMemberFieldCount);
writer.Write(member.Slot);
writer.Write(member.MemberName);
writer.Write(member.TypeName);
writer.Write(member.IsReference);
writer.Write(member.IsMany);
writer.Write(member.TargetSchemaName);
writer.Write(member.IsName);
writer.Write(member.IndexAlias);
}
private static CultSchemaMemberCatalogEntry ReadSchemaCatalogMember(ref MessagePackReader reader)
{
var fieldCount = reader.ReadArrayHeader();
var member = new CultSchemaMemberCatalogEntry();
if (fieldCount > 0)
{
member.Slot = reader.ReadInt32();
}
if (fieldCount > 1)
{
member.MemberName = reader.ReadString() ?? string.Empty;
}
if (fieldCount > 2)
{
member.TypeName = reader.ReadString() ?? string.Empty;
}
if (fieldCount > 3)
{
member.IsReference = reader.ReadBoolean();
}
if (fieldCount > 4)
{
member.IsMany = reader.ReadBoolean();
}
if (fieldCount > 5)
{
member.TargetSchemaName = reader.ReadString();
}
if (fieldCount > 6)
{
member.IsName = reader.ReadBoolean();
}
if (fieldCount > 7)
{
member.IndexAlias = reader.ReadString();
}
for (var index = SchemaCatalogMemberFieldCount; index < fieldCount; index++)
{
reader.Skip();
}
return member;
}
}
/// <summary>

View file

@ -0,0 +1,60 @@
# CultCache Schema Compatibility
CultCache compatibility is slot-driven and explicit. The cache will not pretend
that "same-ish looking" schemas are fine just because the names feel friendly.
## Semantic identity
Two CultCache document schemas are considered the same shape when they agree on:
- `schemaName`
- `schemaVersion`
- member slots
- member persisted type names
- reference/cardinality semantics
- target schema names for references
- name/index lookup participation
The canonical fixtures currently exercised in C# are:
- `tests.named_entry`
- schema id: `sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8`
- content hash: `sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e`
- `tests.reference_holder`
- schema id: `sha256:bd85064961cc74565fb73e3ccbc4217cfba4dc4869e365a08bea4f704739bd8f`
See `GameCult.Caching.Tests/BackingStoreTests.cs` for the canonical fixture
documents and the expected receipts.
## Soft-migratable drift
CultCache accepts compatible drift only when the local reader can still map the
persisted slots honestly:
- persisted schema id differs, but the embedded catalog points to a compatible
local schema id
- persisted slot is missing locally and can be ignored
- local slot is missing in persisted data and can fall back to the local
default value
The cache emits a typed migration report with:
- exact vs compatible-drift classification
- ignored extra slots
- defaulted missing slots
- warning codes and messages
## Hard rejection
CultCache rejects persisted schemas when any slot changes in a way that would
lie about the payload:
- type change
- reference vs value change
- one vs many change
- target schema change
- name/index lookup semantics change
- missing embedded catalog entry
- no compatible local schema candidate
This is not negotiable. Better a loud refusal than quiet bit-rot.

View file

@ -42,6 +42,57 @@ namespace GameCult.Caching
/// Gets or sets schema identifiers compatible with this entry.
/// </summary>
public string[] CompatibleSchemaIds { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the persisted member descriptors used for compatibility checks.
/// </summary>
public CultSchemaMemberCatalogEntry[] Members { get; set; } = Array.Empty<CultSchemaMemberCatalogEntry>();
}
/// <summary>
/// Persisted catalog entry describing one schema member.
/// </summary>
public sealed class CultSchemaMemberCatalogEntry
{
/// <summary>
/// Gets or sets the persisted slot number.
/// </summary>
public int Slot { get; set; }
/// <summary>
/// Gets or sets the member name.
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the persisted type name.
/// </summary>
public string TypeName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether the member is a reference.
/// </summary>
public bool IsReference { get; set; }
/// <summary>
/// Gets or sets whether the member stores many values.
/// </summary>
public bool IsMany { get; set; }
/// <summary>
/// Gets or sets the referenced schema name, when the member is a reference.
/// </summary>
public string? TargetSchemaName { get; set; }
/// <summary>
/// Gets or sets whether the member provides the name lookup.
/// </summary>
public bool IsName { get; set; }
/// <summary>
/// Gets or sets the index alias, when the member participates in lookups.
/// </summary>
public string? IndexAlias { get; set; }
}
/// <summary>
@ -86,6 +137,97 @@ namespace GameCult.Caching
public CultPersistedRecord[] Records { get; set; } = Array.Empty<CultPersistedRecord>();
}
/// <summary>
/// Classifies how a persisted schema mapped onto the local document registry.
/// </summary>
public enum CultSchemaMigrationKind
{
/// <summary>
/// The persisted schema matched the local schema exactly.
/// </summary>
Exact,
/// <summary>
/// The persisted schema required a compatible soft-migration path.
/// </summary>
CompatibleDrift
}
/// <summary>
/// Describes one schema-resolution warning emitted during load.
/// </summary>
public sealed class CultSchemaMigrationWarning
{
/// <summary>
/// Gets or sets the stable warning code.
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the human-readable warning text.
/// </summary>
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// Reports how one persisted schema entry resolved against the local schema catalog.
/// </summary>
public sealed class CultSchemaMigrationReport
{
/// <summary>
/// Gets or sets the persisted schema identifier.
/// </summary>
public string PersistedSchemaId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the local schema identifier selected for reading.
/// </summary>
public string LocalSchemaId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the persisted schema name.
/// </summary>
public string PersistedSchemaName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the local schema name.
/// </summary>
public string LocalSchemaName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets how the persisted schema matched locally.
/// </summary>
public CultSchemaMigrationKind Kind { get; set; }
/// <summary>
/// Gets or sets the persisted member slots ignored by the local reader.
/// </summary>
public int[] IgnoredExtraSlots { get; set; } = Array.Empty<int>();
/// <summary>
/// Gets or sets the local member slots defaulted because the persisted record did not contain them.
/// </summary>
public int[] DefaultedMissingSlots { get; set; } = Array.Empty<int>();
/// <summary>
/// Gets or sets emitted migration warnings.
/// </summary>
public CultSchemaMigrationWarning[] Warnings { get; set; } = Array.Empty<CultSchemaMigrationWarning>();
}
internal sealed class CultSchemaResolutionResult
{
public CultSchemaResolutionResult(CultDocumentDescriptor descriptor, CultSchemaMigrationReport report)
{
Descriptor = descriptor;
Report = report;
}
public CultDocumentDescriptor Descriptor { get; }
public CultSchemaMigrationReport Report { get; }
}
/// <summary>
/// Runtime descriptor for a CultCache document type.
/// </summary>
@ -177,7 +319,21 @@ namespace GameCult.Caching
SchemaVersion = SchemaVersion,
ContentHash = ContentHash,
CanonicalSchemaJson = CanonicalSchemaJson,
CompatibleSchemaIds = [SchemaId]
CompatibleSchemaIds = [SchemaId],
Members = Members
.OrderBy(member => member.Slot)
.Select(member => new CultSchemaMemberCatalogEntry
{
Slot = member.Slot,
MemberName = member.MemberName,
TypeName = member.TypeName,
IsReference = member.IsReference,
IsMany = member.IsMany,
TargetSchemaName = member.TargetSchemaName,
IsName = member.IsName,
IndexAlias = member.IndexAlias
})
.ToArray()
};
}
@ -349,10 +505,32 @@ namespace GameCult.Caching
/// Resolves a persisted schema identifier against the local registry and embedded catalog.
/// </summary>
public CultDocumentDescriptor ResolvePersistedSchema(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog)
{
return ResolvePersistedSchemaDetailed(schemaId, catalog).Descriptor;
}
/// <summary>
/// Resolves a persisted schema identifier and returns the selected descriptor with migration diagnostics.
/// </summary>
public CultSchemaMigrationReport ResolvePersistedSchemaReport(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog)
{
return ResolvePersistedSchemaDetailed(schemaId, catalog).Report;
}
internal CultSchemaResolutionResult ResolvePersistedSchemaDetailed(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog)
{
if (_bySchemaId.TryGetValue(schemaId, out var exact))
{
return exact;
return new CultSchemaResolutionResult(
exact,
new CultSchemaMigrationReport
{
PersistedSchemaId = schemaId,
LocalSchemaId = exact.SchemaId,
PersistedSchemaName = exact.SchemaName,
LocalSchemaName = exact.SchemaName,
Kind = CultSchemaMigrationKind.Exact
});
}
var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal));
@ -361,9 +539,17 @@ namespace GameCult.Caching
throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog.");
}
foreach (var compatibleSchemaId in persisted.CompatibleSchemaIds.Where(candidate => !string.IsNullOrWhiteSpace(candidate)))
{
if (_bySchemaId.TryGetValue(compatibleSchemaId, out var compatibleLocal))
{
return BuildCompatibleResolutionResult(persisted, compatibleLocal);
}
}
if (_bySchemaName.TryGetValue(persisted.SchemaName, out var local))
{
return local;
return BuildCompatibleResolutionResult(persisted, local);
}
throw new InvalidOperationException(
@ -556,6 +742,255 @@ namespace GameCult.Caching
return builder.ToString();
}
private static CultSchemaResolutionResult BuildCompatibleResolutionResult(
CultSchemaCatalogEntry persisted,
CultDocumentDescriptor local)
{
if (string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal))
{
return new CultSchemaResolutionResult(
local,
new CultSchemaMigrationReport
{
PersistedSchemaId = persisted.SchemaId,
LocalSchemaId = local.SchemaId,
PersistedSchemaName = persisted.SchemaName,
LocalSchemaName = local.SchemaName,
Kind = CultSchemaMigrationKind.Exact
});
}
var persistedSchema = new CanonicalSchemaShape(
persisted.SchemaName,
persisted.SchemaVersion,
persisted.Members.Select(member => new CanonicalSchemaMember(
member.Slot,
member.MemberName,
member.TypeName,
member.IsReference,
member.IsMany,
member.TargetSchemaName,
member.IsName,
member.IndexAlias)).ToArray());
var localSchema = new CanonicalSchemaShape(
local.SchemaName,
local.SchemaVersion,
local.Members.Select(member => new CanonicalSchemaMember(
member.Slot,
member.MemberName,
member.TypeName,
member.IsReference,
member.IsMany,
member.TargetSchemaName,
member.IsName,
member.IndexAlias)).ToArray());
var comparison = CompareSchemaShapes(persistedSchema, localSchema);
if (!comparison.IsCompatible)
{
throw new InvalidOperationException(
$"Persisted schema '{persisted.SchemaName}' ({persisted.SchemaId}) is incompatible with local schema '{local.SchemaName}' ({local.SchemaId}): {string.Join("; ", comparison.Errors)}");
}
var warnings = new List<CultSchemaMigrationWarning>();
if (!string.Equals(persisted.SchemaId, local.SchemaId, StringComparison.Ordinal))
{
warnings.Add(new CultSchemaMigrationWarning
{
Code = "compatible_schema_id_drift",
Message = $"Persisted schema '{persisted.SchemaId}' resolved to local schema '{local.SchemaId}'."
});
}
if (!string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal))
{
warnings.Add(new CultSchemaMigrationWarning
{
Code = "content_hash_drift",
Message = $"Persisted schema content hash '{persisted.ContentHash}' differs from local hash '{local.ContentHash}'."
});
}
warnings.AddRange(comparison.Warnings);
return new CultSchemaResolutionResult(
local,
new CultSchemaMigrationReport
{
PersistedSchemaId = persisted.SchemaId,
LocalSchemaId = local.SchemaId,
PersistedSchemaName = persisted.SchemaName,
LocalSchemaName = local.SchemaName,
Kind = CultSchemaMigrationKind.CompatibleDrift,
IgnoredExtraSlots = comparison.IgnoredExtraSlots.OrderBy(slot => slot).ToArray(),
DefaultedMissingSlots = comparison.DefaultedMissingSlots.OrderBy(slot => slot).ToArray(),
Warnings = warnings.ToArray()
});
}
private static SchemaShapeComparison CompareSchemaShapes(CanonicalSchemaShape persisted, CanonicalSchemaShape local)
{
var errors = new List<string>();
var warnings = new List<CultSchemaMigrationWarning>();
var ignoredExtraSlots = new List<int>();
var defaultedMissingSlots = new List<int>();
if (!string.Equals(persisted.SchemaName, local.SchemaName, StringComparison.Ordinal))
{
errors.Add($"schema name drift '{persisted.SchemaName}' -> '{local.SchemaName}'");
}
var persistedBySlot = persisted.Members.ToDictionary(member => member.Slot);
var localBySlot = local.Members.ToDictionary(member => member.Slot);
foreach (var localMember in local.Members.OrderBy(member => member.Slot))
{
if (!persistedBySlot.TryGetValue(localMember.Slot, out var persistedMember))
{
defaultedMissingSlots.Add(localMember.Slot);
warnings.Add(new CultSchemaMigrationWarning
{
Code = "defaulted_missing_slot",
Message = $"Local slot {localMember.Slot} ('{localMember.Name}') is missing from the persisted schema and will use the local default value."
});
continue;
}
if (!string.Equals(localMember.TypeName, persistedMember.TypeName, StringComparison.Ordinal))
{
errors.Add($"slot {localMember.Slot} changed type '{persistedMember.TypeName}' -> '{localMember.TypeName}'");
}
if (localMember.IsReference != persistedMember.IsReference)
{
errors.Add($"slot {localMember.Slot} changed reference semantics");
}
if (localMember.IsMany != persistedMember.IsMany)
{
errors.Add($"slot {localMember.Slot} changed cardinality");
}
if (!string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal))
{
errors.Add($"slot {localMember.Slot} changed target schema '{persistedMember.TargetSchemaName ?? "<none>"}' -> '{localMember.TargetSchemaName ?? "<none>"}'");
}
if (localMember.IsName != persistedMember.IsName)
{
errors.Add($"slot {localMember.Slot} changed name-lookup semantics");
}
if (!string.Equals(localMember.IndexAlias, persistedMember.IndexAlias, StringComparison.Ordinal))
{
errors.Add($"slot {localMember.Slot} changed index alias '{persistedMember.IndexAlias ?? "<none>"}' -> '{localMember.IndexAlias ?? "<none>"}'");
}
}
foreach (var persistedMember in persisted.Members.OrderBy(member => member.Slot))
{
if (localBySlot.ContainsKey(persistedMember.Slot))
{
continue;
}
ignoredExtraSlots.Add(persistedMember.Slot);
warnings.Add(new CultSchemaMigrationWarning
{
Code = "ignored_extra_slot",
Message = $"Persisted slot {persistedMember.Slot} ('{persistedMember.Name}') is not present locally and will be ignored."
});
}
return new SchemaShapeComparison(
errors.Count == 0,
errors.ToArray(),
warnings.ToArray(),
ignoredExtraSlots.ToArray(),
defaultedMissingSlots.ToArray());
}
private sealed class CanonicalSchemaShape
{
public CanonicalSchemaShape(string schemaName, string schemaVersion, CanonicalSchemaMember[] members)
{
SchemaName = schemaName;
SchemaVersion = schemaVersion;
Members = members;
}
public string SchemaName { get; }
public string SchemaVersion { get; }
public CanonicalSchemaMember[] Members { get; }
}
private sealed class CanonicalSchemaMember
{
public CanonicalSchemaMember(
int slot,
string name,
string typeName,
bool isReference,
bool isMany,
string? targetSchemaName,
bool isName,
string? indexAlias)
{
Slot = slot;
Name = name;
TypeName = typeName;
IsReference = isReference;
IsMany = isMany;
TargetSchemaName = targetSchemaName;
IsName = isName;
IndexAlias = indexAlias;
}
public int Slot { get; }
public string Name { get; }
public string TypeName { get; }
public bool IsReference { get; }
public bool IsMany { get; }
public string? TargetSchemaName { get; }
public bool IsName { get; }
public string? IndexAlias { get; }
}
private sealed class SchemaShapeComparison
{
public SchemaShapeComparison(
bool isCompatible,
string[] errors,
CultSchemaMigrationWarning[] warnings,
int[] ignoredExtraSlots,
int[] defaultedMissingSlots)
{
IsCompatible = isCompatible;
Errors = errors;
Warnings = warnings;
IgnoredExtraSlots = ignoredExtraSlots;
DefaultedMissingSlots = defaultedMissingSlots;
}
public bool IsCompatible { get; }
public string[] Errors { get; }
public CultSchemaMigrationWarning[] Warnings { get; }
public int[] IgnoredExtraSlots { get; }
public int[] DefaultedMissingSlots { get; }
}
private static IReadOnlyList<PersistedMember> DiscoverMembers(Type type)
{
var members = new List<PersistedMember>();
@ -725,6 +1160,7 @@ namespace GameCult.Caching
private readonly ConcurrentDictionary<Type, string> _globalKeys = new();
private readonly ConditionalWeakTable<object, DocumentHandleBox> _documentHandles = new();
private ILogger _logger = new NullLogger();
private bool _hasUnflushedMutations;
/// <summary>
/// Creates a cache using the supplied document registry or the shared registry.
@ -744,11 +1180,31 @@ namespace GameCult.Caching
set => _logger = value ?? new NullLogger();
}
/// <summary>
/// Gets whether the cache currently holds unflushed mutations in any attached backing store or only in memory.
/// </summary>
public bool IsDirty => _hasUnflushedMutations || _backingStores.Any(store => store.IsDirty);
/// <summary>
/// Gets the UTC timestamp of the last successful flush across all attached backing stores.
/// </summary>
public DateTimeOffset? LastSuccessfulFlushAtUtc { get; private set; }
/// <summary>
/// Gets or sets whether disposing the cache should flush attached dirty backing stores first.
/// </summary>
public bool FlushAttachedStoresOnDispose { get; set; }
/// <summary>
/// Raised when a backing store adds, updates, or removes a document.
/// </summary>
public event Action<object?, object?>? OnUpdate;
/// <summary>
/// Gets attached backing stores in registration order.
/// </summary>
public IReadOnlyList<CacheBackingStore> BackingStores => _backingStores;
/// <summary>
/// Gets all document instances currently held by the cache.
/// </summary>
@ -770,6 +1226,12 @@ namespace GameCult.Caching
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));
foreach (var entry in _entries.Values.OrderBy(entry => entry.Key.Value, StringComparer.Ordinal))
{
store.Push(entry);
}
_backingStores.Add(store);
}
@ -783,9 +1245,54 @@ namespace GameCult.Caching
store.PullAll();
}
_hasUnflushedMutations = _backingStores.Any(store => store.IsDirty);
await Task.CompletedTask;
}
/// <summary>
/// Flushes all attached backing stores.
/// </summary>
public void FlushAllBackingStores(bool soft = false)
{
foreach (var store in _backingStores)
{
FlushBackingStore(store, soft);
}
RecomputeDirtyState();
if (!IsDirty)
{
LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow;
}
}
/// <summary>
/// Flushes one attached backing store.
/// </summary>
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.");
}
store.PushAll(soft);
RecomputeDirtyState();
if (!store.IsDirty)
{
LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow;
}
}
/// <summary>
/// Flushes attached backing stores at a lifecycle boundary such as shutdown or assembly reload.
/// </summary>
public void PrepareForReloadOrShutdown(bool soft = false)
{
FlushAllBackingStores(soft);
}
/// <summary>
/// Adds or replaces a typed document and returns its record handle.
/// </summary>
@ -902,6 +1409,11 @@ namespace GameCult.Caching
/// </summary>
public void Dispose()
{
if (FlushAttachedStoresOnDispose && IsDirty)
{
FlushAllBackingStores();
}
foreach (var store in _backingStores.OfType<IDisposable>())
{
store.Dispose();
@ -944,6 +1456,15 @@ namespace GameCult.Caching
}
}
if (source == null)
{
_hasUnflushedMutations = true;
}
else
{
RecomputeDirtyState();
}
if (raiseUpdate)
{
OnUpdate?.Invoke(existing?.Document, stored.Document);
@ -974,6 +1495,15 @@ namespace GameCult.Caching
}
}
if (source == null)
{
_hasUnflushedMutations = true;
}
else
{
RecomputeDirtyState();
}
if (raiseUpdate)
{
OnUpdate?.Invoke(existing.Document, null);
@ -1088,6 +1618,13 @@ namespace GameCult.Caching
public CultRecordKey Key { get; }
}
private void RecomputeDirtyState()
{
_hasUnflushedMutations = _backingStores.Count == 0
? _hasUnflushedMutations
: _backingStores.Any(store => store.IsDirty);
}
}
/// <summary>
@ -1097,6 +1634,8 @@ namespace GameCult.Caching
{
private CultDocumentRegistry? _registry;
private ILogger _logger = new NullLogger();
private bool _isDirty;
private CultSchemaMigrationReport[] _lastSchemaMigrationReports = Array.Empty<CultSchemaMigrationReport>();
/// <summary>
/// Gets the attached document registry.
@ -1119,6 +1658,30 @@ namespace GameCult.Caching
set => _logger = value ?? new NullLogger();
}
/// <summary>
/// Gets whether the backing store holds staged mutations not yet durably flushed.
/// </summary>
public bool IsDirty
{
get => _isDirty;
protected set => _isDirty = value;
}
/// <summary>
/// Gets the UTC timestamp of the last successful durable flush.
/// </summary>
public DateTimeOffset? LastSuccessfulFlushAtUtc { get; protected set; }
/// <summary>
/// Gets or sets whether disposing the backing store should flush staged mutations first.
/// </summary>
public bool FlushOnDispose { get; set; }
/// <summary>
/// Gets schema migration reports emitted during the most recent pull.
/// </summary>
public IReadOnlyList<CultSchemaMigrationReport> LastSchemaMigrationReports => _lastSchemaMigrationReports;
/// <summary>
/// Publishes documents added by the backing store.
/// </summary>
@ -1158,6 +1721,11 @@ namespace GameCult.Caching
/// </summary>
public virtual void Dispose()
{
if (FlushOnDispose && IsDirty)
{
PushAll();
}
EntryAdded.Dispose();
EntryUpdated.Dispose();
EntryDeleted.Dispose();
@ -1185,14 +1753,31 @@ namespace GameCult.Caching
IReadOnlyCollection<CultSchemaCatalogEntry> catalog,
Func<Type, byte[], object> deserializePayload)
{
var descriptor = Registry.ResolvePersistedSchema(record.SchemaId, catalog);
var document = deserializePayload(descriptor.DocumentType, record.Payload);
var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog);
var document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload);
return new CultStoredDocument(
new CultRecordKey(record.Key),
record.StoredAt,
descriptor,
resolution.Descriptor,
document);
}
/// <summary>
/// Records schema migration reports captured during a pull.
/// </summary>
protected void SetLastSchemaMigrationReports(IEnumerable<CultSchemaMigrationReport> reports)
{
_lastSchemaMigrationReports = reports?.ToArray() ?? Array.Empty<CultSchemaMigrationReport>();
}
/// <summary>
/// Marks the backing store as durably flushed.
/// </summary>
protected void MarkFlushSucceeded()
{
IsDirty = false;
LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow;
}
}
/// <summary>
@ -1236,16 +1821,23 @@ namespace GameCult.Caching
{
if (!FileInfo.Exists)
{
SetLastSchemaMigrationReports(Array.Empty<CultSchemaMigrationReport>());
IsDirty = false;
return;
}
var snapshot = DeserializeSnapshot(File.ReadAllBytes(FileInfo.FullName));
var reports = new List<CultSchemaMigrationReport>(snapshot.Records.Length);
foreach (var record in snapshot.Records)
{
reports.Add(Registry.ResolvePersistedSchemaReport(record.SchemaId, snapshot.SchemaCatalog));
var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload);
Entries[stored.Key.Value] = stored;
EntryAdded.OnNext(stored);
}
SetLastSchemaMigrationReports(reports);
IsDirty = false;
}
/// <summary>
@ -1254,6 +1846,7 @@ namespace GameCult.Caching
public override void Push(CultStoredDocument entry)
{
Entries[entry.Key.Value] = entry;
IsDirty = true;
}
/// <summary>
@ -1262,6 +1855,7 @@ namespace GameCult.Caching
public override void Delete(CultStoredDocument entry)
{
Entries.TryRemove(entry.Key.Value, out _);
IsDirty = true;
}
/// <summary>
@ -1285,6 +1879,7 @@ namespace GameCult.Caching
Directory.CreateDirectory(FileInfo.DirectoryName!);
WriteSnapshotAtomically(FileInfo.FullName, SerializeSnapshot(snapshot));
MarkFlushSucceeded();
}
private static void WriteSnapshotAtomically(string path, byte[] payload)

View file

@ -133,3 +133,39 @@ var store = new SingleFileMessagePackBackingStore("Data.msgpack");
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
```
## Persistence discipline
Persistence stays manual by default. Mutations mark the cache and attached
backing stores dirty; nothing silently flushes every object write just because
that felt convenient in the moment.
Useful surfaces:
- `cache.IsDirty`
- `store.IsDirty`
- `cache.FlushAllBackingStores()`
- `cache.FlushBackingStore(store)`
- `cache.PrepareForReloadOrShutdown()`
- `cache.FlushAttachedStoresOnDispose`
- `store.FlushOnDispose`
This is built for callers like Aquarium that want durable runtime state while
still choosing *when* to pay the write cost.
Single-file MessagePack remains a single-writer format. The atomic replace path
protects against partial writes, not competing writers trampling each other.
## Schema migration diagnostics
CultCache does not quietly fall back from exact `schemaId` resolution to
"close enough, probably." When a persisted schema drifts, the cache now emits a
typed migration report describing:
- exact vs compatible-drift resolution
- ignored extra slots
- defaulted missing slots
- warning codes/messages
Compatibility rules and canonical fixture receipts live in
`Contracts/cultcache-schema-compatibility.md`.

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using GameCult.Logging;
using LiteNetLib;
using MessagePack;
@ -7,6 +8,27 @@ using R3;
namespace GameCult.Networking
{
/// <summary>
/// Describes the client's reconnect posture.
/// </summary>
public enum ClientReconnectState
{
/// <summary>
/// The client is connected or otherwise not trying to reconnect.
/// </summary>
Idle,
/// <summary>
/// The client is waiting for the next reconnect attempt.
/// </summary>
WaitingToReconnect,
/// <summary>
/// The client is actively attempting a reconnect.
/// </summary>
Reconnecting
}
/// <summary>
/// Provides client-side connection, authentication, and message dispatch logic.
/// </summary>
@ -16,6 +38,10 @@ namespace GameCult.Networking
/// Raised when the client encounters a user-visible error.
/// </summary>
public event Action<string>? OnError; // Hook for important user-facing feedback (e.g. via modal UI element)
/// <summary>
/// Raised when reconnect state changes.
/// </summary>
public event Action<ClientReconnectState>? OnReconnectStateChanged;
private NetManager? _client;
private NetPeer? _peer;
private IDisposable? _pollSubscription;
@ -23,6 +49,7 @@ namespace GameCult.Networking
private bool _disposed;
private bool _manualDisconnect;
private bool _isReconnecting;
private int _reconnectAttemptCount;
private readonly ConcurrentDictionary<Type, Delegate> _messageDelegates = new();
@ -31,7 +58,9 @@ namespace GameCult.Networking
private int _lastPort;
private readonly ClientSecurityOptions _security;
private ILogger _logger = new NullLogger();
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan BaseReconnectDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan MaxReconnectDelay = TimeSpan.FromSeconds(30);
private static readonly TimeSpan MaxReconnectJitter = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Gets or sets the logger used by the client.
@ -47,6 +76,16 @@ namespace GameCult.Networking
/// </summary>
public int Ping { get; private set; }
/// <summary>
/// Gets the current reconnect posture exposed to consumers.
/// </summary>
public ClientReconnectState ReconnectState { get; private set; } = ClientReconnectState.Idle;
/// <summary>
/// Gets or sets whether raw payload bodies may be logged for diagnostics.
/// </summary>
public bool LogSensitivePayloads { get; set; }
/// <summary>
/// Gets a value indicating whether the client currently has a verified session.
/// </summary>
@ -83,7 +122,14 @@ namespace GameCult.Networking
{
if (Verified || m is LoginMessage || m is RegisterMessage || m is VerifyMessage)
{
Logger.LogDebug($"Sending message {MessagePackSerializer.SerializeToJson(m)}");
if (LogSensitivePayloads)
{
Logger.LogDebug($"Sending message {MessagePackSerializer.SerializeToJson(m)}");
}
else
{
Logger.LogDebug($"Sending message {typeof(T).Name}");
}
_peer.Send(m);
}
else Logger.LogError("Cannot send, client is not verified!");
@ -192,6 +238,11 @@ namespace GameCult.Networking
/// <param name="host">The remote host name or IP address.</param>
/// <param name="port">The remote server port.</param>
public void Connect(string host = "localhost", int port = 3075)
{
ConnectCore(host, port, resetReconnectBackoff: true);
}
private void ConnectCore(string host, int port, bool resetReconnectBackoff)
{
if (_disposed)
{
@ -200,6 +251,11 @@ namespace GameCult.Networking
_manualDisconnect = false;
_isReconnecting = false;
if (resetReconnectBackoff)
{
_reconnectAttemptCount = 0;
}
SetReconnectState(ClientReconnectState.Idle);
_reconnectSubscription?.Dispose();
_reconnectSubscription = null;
_lastHost = host;
@ -221,8 +277,15 @@ namespace GameCult.Networking
try
{
var bytes = reader.GetRemainingBytes();
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(bytes))}");
var message = MessageSerialization.Deserialize<Message>(bytes);
if (LogSensitivePayloads)
{
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(bytes))}");
}
else
{
Logger.LogDebug($"Received message {message.GetType().Name}");
}
var type = message.GetType();
switch (message)
@ -252,6 +315,8 @@ namespace GameCult.Networking
{
Logger.LogInfo($"Peer {peer.Address}:{peer.Port} connected.");
_peer = peer;
_reconnectAttemptCount = 0;
SetReconnectState(ClientReconnectState.Idle);
if (Verified)
{
var nonce = Secret.NewNonce;
@ -282,6 +347,7 @@ namespace GameCult.Networking
_manualDisconnect = true;
_reconnectSubscription?.Dispose();
_reconnectSubscription = null;
SetReconnectState(ClientReconnectState.Idle);
DisposeTransport();
}
@ -297,6 +363,7 @@ namespace GameCult.Networking
_manualDisconnect = true;
_reconnectSubscription?.Dispose();
_reconnectSubscription = null;
SetReconnectState(ClientReconnectState.Idle);
DisposeTransport();
}
@ -308,15 +375,19 @@ namespace GameCult.Networking
}
_isReconnecting = true;
_reconnectSubscription = Observable.Timer(ReconnectDelay).Subscribe(_ =>
_reconnectAttemptCount++;
var delay = GetReconnectDelayForAttempt(_reconnectAttemptCount);
SetReconnectState(ClientReconnectState.WaitingToReconnect);
_reconnectSubscription = Observable.Timer(delay).Subscribe(_ =>
{
_isReconnecting = false;
_reconnectSubscription?.Dispose();
_reconnectSubscription = null;
SetReconnectState(ClientReconnectState.Reconnecting);
try
{
Connect(_lastHost, _lastPort);
ConnectCore(_lastHost, _lastPort, resetReconnectBackoff: false);
}
catch (Exception ex)
{
@ -326,6 +397,16 @@ namespace GameCult.Networking
});
}
internal static TimeSpan GetReconnectDelayForAttempt(int attempt)
{
var normalizedAttempt = Math.Max(1, attempt);
var exponentialSeconds = Math.Min(
MaxReconnectDelay.TotalSeconds,
BaseReconnectDelay.TotalSeconds * Math.Pow(2, normalizedAttempt - 1));
var jitterMilliseconds = RandomNumberGenerator.GetInt32((int)MaxReconnectJitter.TotalMilliseconds + 1);
return TimeSpan.FromSeconds(exponentialSeconds) + TimeSpan.FromMilliseconds(jitterMilliseconds);
}
private void DisposeTransport()
{
_pollSubscription?.Dispose();
@ -356,5 +437,16 @@ namespace GameCult.Networking
}
}
}
private void SetReconnectState(ClientReconnectState state)
{
if (ReconnectState == state)
{
return;
}
ReconnectState = state;
OnReconnectStateChanged?.Invoke(state);
}
}
}

View file

@ -7,7 +7,7 @@ namespace GameCult.Networking
/// <summary>
/// Basic player account document used by the sample networking stack.
/// </summary>
[CultDocument("gamecult.player_data", "gamecult.player_data.v1")]
[CultDocument("gamecult.player_data", "gamecult.player_data.v2")]
public class PlayerData
{
/// <summary>
@ -36,5 +36,11 @@ namespace GameCult.Networking
[Key(3)]
[CultName]
public string Username = string.Empty;
/// <summary>
/// Monotonic session version used to supersede older issued tokens.
/// </summary>
[Key(4)]
public long SessionVersion;
}
}

View file

@ -23,6 +23,11 @@ The library currently includes:
The library is focused on the built-in authentication and session flows in this repository.
Current readiness target:
- trusted local mesh / self-hosted swarm use: yes
- hostile public internet edge: not yet
Keep the distinction clean:
- auth/session semantics belong to the core library
@ -98,6 +103,9 @@ The built-in flow is:
5. server validates the token and re-establishes the session
The session token is signed and validated by the server.
Each newly issued session token also carries a monotonic session version so
older signed tokens can be superseded cleanly instead of haunting reconnect
paths forever.
## Runtime Secrets
@ -218,8 +226,9 @@ The client:
- disposes its prior polling subscription before reconnecting
- stops the prior `NetManager`
- schedules reconnect with a short delay
- schedules reconnect with bounded exponential backoff and jitter
- automatically re-verifies using the stored signed session token
- exposes reconnect state for UI/operator surfaces
## Important Constraints
@ -227,6 +236,10 @@ The client:
- Message authorization is based on the built-in verify/login/register flow.
- Transport is LiteNetLib, so this is not a drop-in substitute for HTTP or WebSocket stacks.
Sensitive payload logging is gated. Raw message JSON should only appear when a
caller explicitly opts into diagnostic logging instead of accidentally bleeding
live auth/session payloads into ordinary logs.
## Message Example
Messages derive from `Message` and are MessagePack-serializable:
@ -258,3 +271,10 @@ For the newer schema-v0 lane, the equivalent rule is:
- keep payload codecs in sync
- advertise supported contracts through schema discovery
- do not rely on implicit runtime magic to decide what a peer meant
## Future hostile-network work
The next hardening tranche is documented in
`production-hardening-checklist.md`. That ledger names the missing transport,
replay, abuse-handling, and adversarial-test organs directly instead of
pretending local-mesh readiness and internet readiness are the same rite.

View file

@ -193,14 +193,20 @@ namespace GameCult.Networking
/// <param name="expiresAtUtc">The UTC expiration time for the token.</param>
/// <returns>A tamper-evident session token.</returns>
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc) =>
CreateSessionToken(userId, expiresAtUtc, GetDefaultOptions());
CreateSessionToken(userId, expiresAtUtc, sessionVersion: 0, GetDefaultOptions());
/// <summary>
/// Creates a signed session token for the supplied user.
/// </summary>
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, ServerSecurityOptions options)
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, ServerSecurityOptions options) =>
CreateSessionToken(userId, expiresAtUtc, sessionVersion: 0, options);
/// <summary>
/// Creates a signed session token for the supplied user and session version.
/// </summary>
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, long sessionVersion, ServerSecurityOptions options)
{
var payload = $"{userId:N}|{expiresAtUtc.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}";
var payload = $"{userId:N}|{expiresAtUtc.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}|{sessionVersion.ToString(CultureInfo.InvariantCulture)}";
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var signatureBytes = ComputeHmacSha256(options.GetSessionSigningKey(), payloadBytes);
return $"{ToBase64Url(payloadBytes)}.{ToBase64Url(signatureBytes)}";
@ -214,7 +220,17 @@ namespace GameCult.Networking
/// <param name="expiresAtUtc">The extracted UTC expiration time.</param>
/// <returns><c>true</c> when the token is valid and unexpired.</returns>
public static bool TryValidateSessionToken(string? token, out Guid userId, out DateTimeOffset expiresAtUtc) =>
TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc);
TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc, out _);
/// <summary>
/// Validates and parses a signed session token.
/// </summary>
public static bool TryValidateSessionToken(
string? token,
out Guid userId,
out DateTimeOffset expiresAtUtc,
out long sessionVersion) =>
TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc, out sessionVersion);
/// <summary>
/// Validates and parses a signed session token.
@ -224,9 +240,23 @@ namespace GameCult.Networking
ServerSecurityOptions options,
out Guid userId,
out DateTimeOffset expiresAtUtc)
{
return TryValidateSessionToken(token, options, out userId, out expiresAtUtc, out _);
}
/// <summary>
/// Validates and parses a signed session token.
/// </summary>
public static bool TryValidateSessionToken(
string? token,
ServerSecurityOptions options,
out Guid userId,
out DateTimeOffset expiresAtUtc,
out long sessionVersion)
{
userId = Guid.Empty;
expiresAtUtc = DateTimeOffset.MinValue;
sessionVersion = 0;
if (string.IsNullOrWhiteSpace(token))
return false;
@ -245,7 +275,7 @@ namespace GameCult.Networking
var payload = Encoding.UTF8.GetString(payloadBytes);
var payloadParts = payload.Split('|');
if (payloadParts.Length != 2 ||
if ((payloadParts.Length != 2 && payloadParts.Length != 3) ||
!Guid.TryParseExact(payloadParts[0], "N", out userId) ||
!long.TryParse(payloadParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var expiryUnixSeconds))
{
@ -253,6 +283,15 @@ namespace GameCult.Networking
return false;
}
if (payloadParts.Length == 3 &&
!long.TryParse(payloadParts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out sessionVersion))
{
userId = Guid.Empty;
expiresAtUtc = DateTimeOffset.MinValue;
sessionVersion = 0;
return false;
}
expiresAtUtc = DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds);
return expiresAtUtc > DateTimeOffset.UtcNow;
}
@ -260,6 +299,7 @@ namespace GameCult.Networking
{
userId = Guid.Empty;
expiresAtUtc = DateTimeOffset.MinValue;
sessionVersion = 0;
return false;
}
}

View file

@ -25,12 +25,15 @@ namespace GameCult.Networking
private const int ServerPort = 3075;
private const float SessionTimeoutSeconds = 1800; // 30 minutes
private const float SessionRefreshThresholdSeconds = 300; // 5 minutes
private const int MaxConnectionAttemptsPerMinute = 30;
private const int MaxLoginAttemptsPerMinute = 5;
private readonly ConcurrentDictionary<Type, Delegate> _messageDelegates = new();
private readonly ConcurrentDictionary<long, User> _users = new();
private readonly ConcurrentDictionary<string, Queue<DateTimeOffset>> _loginAttempts = new();
private readonly ConcurrentDictionary<string, object> _loginAttemptLocks = new();
private readonly ConcurrentDictionary<string, Queue<DateTimeOffset>> _connectionAttempts = new();
private readonly ConcurrentDictionary<string, object> _connectionAttemptLocks = new();
private readonly ConcurrentDictionary<string, Queue<DateTimeOffset>> _authAttempts = new();
private readonly ConcurrentDictionary<string, object> _authAttemptLocks = new();
private readonly IDisposable _cleanupSubscription;
private readonly CultCache _database;
private readonly ServerSecurityOptions _security;
@ -48,6 +51,11 @@ namespace GameCult.Networking
set => _logger = value ?? new NullLogger();
}
/// <summary>
/// Gets or sets whether raw payload bodies may be logged for diagnostics.
/// </summary>
public bool LogSensitivePayloads { get; set; }
private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d);
/// <summary>
@ -59,6 +67,7 @@ namespace GameCult.Networking
{
_database = cache;
_security = security ?? ServerSecurityOptions.FromEnvironment();
LogSensitivePayloads = _security.IsDevelopment;
_cleanupSubscription = Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60))
.Subscribe(_ => CleanupExpiredSessions());
}
@ -150,7 +159,7 @@ namespace GameCult.Networking
listener.ConnectionRequestEvent += request =>
{
if (CheckRateLimit(request.RemoteEndPoint.Address.ToString()))
if (CheckConnectionRateLimit(request.RemoteEndPoint.Address.ToString()))
{
request.AcceptIfKey(_security.ConnectionKey);
}
@ -186,7 +195,14 @@ namespace GameCult.Networking
{
var bytes = reader.GetRemainingBytes();
var message = MessageSerialization.Deserialize<Message>(bytes);
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(bytes))}");
if (LogSensitivePayloads)
{
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(bytes))}");
}
else
{
Logger.LogDebug($"Received message {message?.GetType().Name ?? "unknown"}");
}
if (message == null)
{
return;
@ -203,7 +219,7 @@ namespace GameCult.Networking
return;
}
if (message is LoginMessage or RegisterMessage && !CheckRateLimit(peer.Address.ToString()))
if (message is LoginMessage or RegisterMessage && !CheckAuthRateLimit(peer.Address.ToString()))
{
peer.Send(new ErrorMessage { Error = "Too Many Attempts" });
return;
@ -343,20 +359,27 @@ namespace GameCult.Networking
private void HandleVerify(NetPeer peer, User user, VerifyMessage verify)
{
var token = Secret.DecryptString(verify.Session, verify.Nonce, _security);
if (!Secret.TryValidateSessionToken(token, _security, out var playerId, out _))
if (!Secret.TryValidateSessionToken(token, _security, out var playerId, out _, out var sessionVersion))
{
peer.Send(new ErrorMessage { Error = "Session Invalid" });
return;
}
if (_database.GetByIndex<PlayerData>("PlayerId", playerId.ToString("D")) == null)
var player = _database.GetByIndex<PlayerData>("PlayerId", playerId.ToString("D"));
if (player == null)
{
peer.Send(new ErrorMessage { Error = "Session Not Found" });
return;
}
if (player.SessionVersion != sessionVersion)
{
peer.Send(new ErrorMessage { Error = "Session Superseded" });
return;
}
AttachUser(user, playerId);
SendSessionToken(peer, playerId);
SendSessionToken(peer, player);
}
private void HandleLogin(NetPeer peer, User user, LoginMessage login)
@ -387,7 +410,7 @@ namespace GameCult.Networking
}
AttachUser(user, userData.PlayerId);
SendSessionToken(peer, userData.PlayerId);
SendSessionToken(peer, userData);
}
private bool IsVerified(User? user) =>
@ -399,23 +422,14 @@ namespace GameCult.Networking
private PlayerData? SessionData(User user) =>
IsVerified(user) ? _database.GetByIndex<PlayerData>("PlayerId", user.PlayerId.ToString("D")) : null;
private bool CheckRateLimit(string ip)
internal bool CheckConnectionRateLimit(string ip)
{
var now = DateTimeOffset.UtcNow;
var windowStart = now.AddMinutes(-1);
var queue = _loginAttempts.GetOrAdd(ip, _ => new Queue<DateTimeOffset>());
var gate = _loginAttemptLocks.GetOrAdd(ip, _ => new object());
return CheckRateLimit(ip, MaxConnectionAttemptsPerMinute, _connectionAttempts, _connectionAttemptLocks);
}
lock (gate)
{
while (queue.Count > 0 && queue.Peek() < windowStart)
{
queue.Dequeue();
}
queue.Enqueue(now);
return queue.Count <= MaxLoginAttemptsPerMinute;
}
internal bool CheckAuthRateLimit(string ip)
{
return CheckRateLimit(ip, MaxLoginAttemptsPerMinute, _authAttempts, _authAttemptLocks);
}
private void CleanupExpiredSessions()
@ -439,14 +453,29 @@ namespace GameCult.Networking
}
private void SendSessionToken(NetPeer peer, Guid playerId)
{
var player = _database.GetByIndex<PlayerData>("PlayerId", playerId.ToString("D"));
if (player == null)
{
peer.Send(new ErrorMessage { Error = "Session Not Found" });
return;
}
SendSessionToken(peer, player);
}
private void SendSessionToken(NetPeer peer, PlayerData player)
{
var expiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds);
var token = Secret.CreateSessionToken(playerId, expiresAtUtc, _security);
player.SessionVersion++;
var token = Secret.CreateSessionToken(player.PlayerId, expiresAtUtc, player.SessionVersion, _security);
_database.AddAsync(player).GetAwaiter().GetResult();
if (_users.TryGetValue(peer.Id, out var user))
{
user.PlayerId = playerId;
user.PlayerId = player.PlayerId;
user.SessionExpiresAt = expiresAtUtc;
user.SessionVersion = player.SessionVersion;
user.SessionToken = token;
}
@ -467,6 +496,29 @@ namespace GameCult.Networking
SendSessionToken(peer, user.PlayerId);
}
private static bool CheckRateLimit(
string ip,
int maxAttemptsPerMinute,
ConcurrentDictionary<string, Queue<DateTimeOffset>> attemptBuckets,
ConcurrentDictionary<string, object> attemptLocks)
{
var now = DateTimeOffset.UtcNow;
var windowStart = now.AddMinutes(-1);
var queue = attemptBuckets.GetOrAdd(ip, _ => new Queue<DateTimeOffset>());
var gate = attemptLocks.GetOrAdd(ip, _ => new object());
lock (gate)
{
while (queue.Count > 0 && queue.Peek() < windowStart)
{
queue.Dequeue();
}
queue.Enqueue(now);
return queue.Count <= maxAttemptsPerMinute;
}
}
}
/// <summary>
@ -514,5 +566,10 @@ namespace GameCult.Networking
/// The latest signed session token issued to the peer.
/// </summary>
public string SessionToken = string.Empty;
/// <summary>
/// The session version associated with the latest issued token.
/// </summary>
public long SessionVersion;
}
}

View file

@ -0,0 +1,76 @@
# GameCult.Networking Production Hardening Checklist
This library is readying itself for trusted local mesh use: localhost, LAN, or
controlled self-hosted runtime clusters such as Epiphany and Aquarium. It is
not yet claiming sainthood on the public internet.
## Local-mesh baseline now covered
- separate connection-attempt and auth-attempt throttles in `Server`
- versioned session tokens that supersede older issued tokens
- bounded reconnect backoff with jitter in `Client`
- raw payload logging gated behind explicit diagnostic opt-in
- schema-first raw document/snapshot lane with exact `schemaId` routing
## Missing hostile-network organs
### Transport posture
- LiteNetLib remains the transport substrate.
- There is no transport-level TLS story in this library today.
- Before internet exposure, decide whether LiteNetLib stays and gains a real
outer protection model, or whether another transport becomes the canonical
public edge.
### Credential and replay posture
- login/register payloads are AES-GCM encrypted with per-message nonces
- session tokens are signed and versioned
- replay resistance is still scoped to token expiry + supersession semantics,
not a broader anti-replay protocol
Before hostile exposure:
- define replay expectations explicitly
- add adversarial tests around token reuse, stale reconnects, and reordered
message delivery
### Session rotation and revocation
- superseded tokens are rejected once a newer session version is issued
- there is still no broader account-session management surface for:
- explicit logout-all
- administrative revocation
- multi-device policy
### Input abuse and malformed payload handling
- malformed MessagePack is rejected by the serializer path
- there is no explicit message-size policy or backpressure contract yet
Before hostile exposure:
- add payload-size ceilings
- add stress tests for oversized and malformed inputs
- decide what gets dropped, logged, or disconnected
### Replication abuse cases
- raw snapshot/document replication assumes peers already share payload schema
and trust boundaries
- there is not yet an adversarial policy for untrusted replication partners
Before hostile exposure:
- define peer authorization for raw snapshot/document lanes
- test unknown schema spam, snapshot flooding, and incompatible payload storms
### Missing tests
Still wanted before claiming internet-grade readiness:
- live client/server auth integration tests
- reconnect storm tests
- session supersession across multiple clients
- message-size and malformed-frame abuse tests
- load and soak tests for rate limiting and session refresh

View file

@ -40,6 +40,88 @@ namespace GameCult.Caching.Tests
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.Value, Is.EqualTo("payload"));
Assert.That(readStore.LastSchemaMigrationReports, Has.Count.EqualTo(1));
Assert.That(readStore.LastSchemaMigrationReports[0].Kind, Is.EqualTo(CultSchemaMigrationKind.Exact));
}
finally
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
[Test]
public async Task CultCache_DirtyState_Tracks_Mutations_And_ExplicitFlush()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack");
try
{
var store = new SingleFileMessagePackBackingStore(filePath);
var cache = new CultCache();
cache.AddBackingStore(store);
Assert.That(cache.IsDirty, Is.False);
Assert.That(store.IsDirty, Is.False);
await cache.AddAsync(new NamedTestEntry
{
Name = "dirty",
Value = "pending"
});
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
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
[Test]
public async Task CultCache_FlushOnDispose_Persists_When_Enabled()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack");
try
{
var store = new SingleFileMessagePackBackingStore(filePath);
var cache = new CultCache
{
FlushAttachedStoresOnDispose = true
};
cache.AddBackingStore(store);
var handle = await cache.AddAsync(new NamedTestEntry
{
Name = "dispose",
Value = "flush"
});
cache.Dispose();
var readStore = new SingleFileMessagePackBackingStore(filePath);
var readCache = new CultCache();
readCache.AddBackingStore(readStore);
await readCache.PullAllBackingStoresAsync();
var loaded = readCache.Get<NamedTestEntry>(handle.Key);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.Value, Is.EqualTo("flush"));
}
finally
{
@ -171,6 +253,66 @@ namespace GameCult.Caching.Tests
Assert.That(descriptor.CanonicalSchemaJson, Does.Contain("\"isReference\":true"));
}
[Test]
public void Registry_CanonicalSchema_Fixtures_Are_Stable()
{
var named = CultDocumentRegistry.Shared.GetRequired<NamedTestEntry>();
var referenceHolder = CultDocumentRegistry.Shared.GetRequired<ReferenceHolderEntry>();
Assert.That(named.CanonicalSchemaJson, Is.EqualTo(NamedFixtureCanonicalSchemaJson));
Assert.That(named.SchemaId, Is.EqualTo(NamedFixtureSchemaId));
Assert.That(named.ContentHash, Is.EqualTo(NamedFixtureContentHash));
Assert.That(referenceHolder.SchemaId, Is.EqualTo(ReferenceFixtureSchemaId));
}
[Test]
public void ResolvePersistedSchemaReport_Classifies_Compatible_And_Incompatible_Drift()
{
var registry = CultDocumentRegistry.Shared;
var namedV1 = registry.GetRequired<NamedTestEntry>();
var namedV2 = registry.GetRequired<NamedTestEntryAdditive>();
var namedV3 = registry.GetRequired<NamedTestEntryRemoved>();
var namedMismatch = registry.GetRequired<NamedTestEntryTypeMismatch>();
var referenceV1 = registry.GetRequired<ReferenceHolderEntry>();
var referenceRetargeted = registry.GetRequired<ReferenceHolderRetargetedEntry>();
var additiveCatalog = namedV1.ToCatalogEntry();
additiveCatalog.SchemaId = "persisted.tests.named_entry.v1";
additiveCatalog.CompatibleSchemaIds = new[] { namedV2.SchemaId };
var additiveReport = registry.ResolvePersistedSchemaReport(additiveCatalog.SchemaId, new[] { additiveCatalog });
Assert.That(additiveReport.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
Assert.That(additiveReport.DefaultedMissingSlots, Is.EqualTo(new[] { 2 }));
Assert.That(additiveReport.IgnoredExtraSlots, Is.Empty);
var removedCatalog = namedV2.ToCatalogEntry();
removedCatalog.SchemaId = "persisted.tests.named_entry.v2";
removedCatalog.CompatibleSchemaIds = new[] { namedV3.SchemaId };
var removedReport = registry.ResolvePersistedSchemaReport(removedCatalog.SchemaId, new[] { removedCatalog });
Assert.That(removedReport.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
Assert.That(removedReport.DefaultedMissingSlots, Is.Empty);
Assert.That(removedReport.IgnoredExtraSlots, Is.EqualTo(new[] { 1, 2 }));
var mismatchCatalog = namedV1.ToCatalogEntry();
mismatchCatalog.SchemaId = "persisted.tests.named_entry.type_mismatch";
mismatchCatalog.CompatibleSchemaIds = new[] { namedMismatch.SchemaId };
Assert.That(
() => registry.ResolvePersistedSchemaReport(mismatchCatalog.SchemaId, new[] { mismatchCatalog }),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed type"));
var retargetedCatalog = referenceV1.ToCatalogEntry();
retargetedCatalog.SchemaId = "persisted.tests.reference_holder.v1";
retargetedCatalog.CompatibleSchemaIds = new[] { referenceRetargeted.SchemaId };
Assert.That(
() => registry.ResolvePersistedSchemaReport(retargetedCatalog.SchemaId, new[] { retargetedCatalog }),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("changed target schema"));
}
private const string NamedFixtureCanonicalSchemaJson =
"{\"schemaName\":\"tests.named_entry\",\"schemaVersion\":\"tests.named_entry.v1\",\"members\":[{\"slot\":0,\"name\":\"Name\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"Value\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}";
private const string NamedFixtureSchemaId = "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8";
private const string NamedFixtureContentHash = "sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e";
private const string ReferenceFixtureSchemaId = "sha256:bd85064961cc74565fb73e3ccbc4217cfba4dc4869e365a08bea4f704739bd8f";
[CultDocument("tests.named_entry", "tests.named_entry.v1")]
internal sealed class NamedTestEntry
{
@ -192,5 +334,57 @@ namespace GameCult.Caching.Tests
[Key(1)]
public CultRecordRef<NamedTestEntry> Parent = new(new CultRecordKey("parent"));
}
[CultDocument("tests.named_entry", "tests.named_entry.v2")]
internal sealed class NamedTestEntryAdditive
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public string Value = string.Empty;
[Key(2)]
public string Notes = string.Empty;
}
[CultDocument("tests.named_entry", "tests.named_entry.v3")]
internal sealed class NamedTestEntryRemoved
{
[Key(0)]
[CultName]
public string Name = string.Empty;
}
[CultDocument("tests.named_entry", "tests.named_entry.v4")]
internal sealed class NamedTestEntryTypeMismatch
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public int Value;
}
[CultDocument("tests.alt_named_entry", "tests.alt_named_entry.v1")]
internal sealed class AlternateNamedTestEntry
{
[Key(0)]
[CultName]
public string Name = string.Empty;
}
[CultDocument("tests.reference_holder", "tests.reference_holder.v2")]
internal sealed class ReferenceHolderRetargetedEntry
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public CultRecordRef<AlternateNamedTestEntry> Parent = new(new CultRecordKey("parent"));
}
}
}

View file

@ -38,6 +38,19 @@ namespace GameCult.Networking.Tests
Assert.That(Secret.TryValidateSessionToken(tamperedToken, DevelopmentServerSecurity, out _, out _), Is.False);
}
[Test]
public void SessionToken_Validates_SessionVersion()
{
var userId = Guid.NewGuid();
var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), 42, DevelopmentServerSecurity);
Assert.That(
Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out _, out var sessionVersion),
Is.True);
Assert.That(parsedUserId, Is.EqualTo(userId));
Assert.That(sessionVersion, Is.EqualTo(42));
}
[Test]
public void SessionToken_RejectsExpiredTokens()
{
@ -267,6 +280,60 @@ namespace GameCult.Networking.Tests
Assert.That(ghostlight.Kind, Is.EqualTo("document_payload"));
}
[Test]
public void Server_Separates_Connection_And_Auth_RateLimits()
{
var cache = new CultCache();
var server = new Server(cache, DevelopmentServerSecurity);
var serverType = typeof(Server);
var connectionMethod = serverType.GetMethod("CheckConnectionRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!;
var authMethod = serverType.GetMethod("CheckAuthRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!;
for (var index = 0; index < 5; index++)
{
Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True);
}
Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True);
for (var index = 0; index < 5; index++)
{
_ = authMethod.Invoke(server, new object[] { "127.0.0.2" });
}
Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.False);
Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.True);
}
[Test]
public void Client_Reconnect_Backoff_Is_Bounded_And_Grows()
{
var method = typeof(Client).GetMethod("GetReconnectDelayForAttempt", BindingFlags.Static | BindingFlags.NonPublic)!;
var first = (TimeSpan)method.Invoke(null, new object[] { 1 })!;
var third = (TimeSpan)method.Invoke(null, new object[] { 3 })!;
var tenth = (TimeSpan)method.Invoke(null, new object[] { 10 })!;
Assert.That(first, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(1)));
Assert.That(first, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(1.25)));
Assert.That(third, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(4)));
Assert.That(third, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(4.25)));
Assert.That(tenth, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(30)));
Assert.That(tenth, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(30.25)));
}
[Test]
public void SensitivePayloadLogging_Is_Gated_By_Default()
{
var client = new Client(DevelopmentClientSecurity);
var productionServer = new Server(new CultCache(), new ServerSecurityOptions("prod-connection-key", "prod-session-signing-secret"));
var developmentServer = new Server(new CultCache(), DevelopmentServerSecurity);
Assert.That(client.LogSensitivePayloads, Is.False);
Assert.That(productionServer.LogSensitivePayloads, Is.False);
Assert.That(developmentServer.LogSensitivePayloads, Is.True);
}
private sealed class EnvironmentVariableScope : IDisposable
{
private readonly (string Name, string? Value)[] _originalValues;
@ -297,6 +364,7 @@ namespace GameCult.Networking.Tests
[MessagePack.Key(1)] public string Email { get; set; } = string.Empty;
[MessagePack.Key(2)] public string PasswordHash { get; set; } = string.Empty;
[MessagePack.Key(3)] public string Username { get; set; } = string.Empty;
[MessagePack.Key(4)] public long SessionVersion { get; set; }
}
private static byte[] SerializePlayerDataPayload(PlayerData entry)
@ -306,7 +374,8 @@ namespace GameCult.Networking.Tests
PlayerId = entry.PlayerId,
Email = entry.Email,
PasswordHash = entry.PasswordHash,
Username = entry.Username
Username = entry.Username,
SessionVersion = entry.SessionVersion
});
}
@ -318,7 +387,8 @@ namespace GameCult.Networking.Tests
PlayerId = decoded.PlayerId,
Email = decoded.Email,
PasswordHash = decoded.PasswordHash,
Username = decoded.Username
Username = decoded.Username,
SessionVersion = decoded.SessionVersion
};
}
}