Harden CultLib for local production use
This commit is contained in:
parent
1917bdb025
commit
3f1cb8b36e
12 changed files with 1376 additions and 47 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
src/GameCult.Networking/production-hardening-checklist.md
Normal file
76
src/GameCult.Networking/production-hardening-checklist.md
Normal 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
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue