Deletes CultMesh's ReadSingleFileSnapshot, HasOlderCatalogLayout, ReadLegacySchemaCatalogEntry, WriteFileAtomically and the File.Exists gate, ReadStore's entry-reader delegate, and the three tests of the older catalog layout. This commit does not build: the Mesh read and write sites still name the deleted reader; the next commit routes them through the CultCache single-file store. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4161 lines
164 KiB
C#
4161 lines
164 KiB
C#
using System;
|
|
using System.Buffers;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using FluentAssertions;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using GameCult.Networking;
|
|
using MessagePack;
|
|
using NUnit.Framework;
|
|
using R3;
|
|
|
|
namespace GameCult.Mesh.Tests;
|
|
|
|
public sealed class CultMeshStreamingTests
|
|
{
|
|
[Test]
|
|
public async Task TypedOperationHandle_CarriesAuthorityAndRouteContext()
|
|
{
|
|
var handle = new CultMeshOperationHandle<MeshMoveRequest, CultMeshOperationReceipt>(
|
|
"aetheria.entity.pilot.move",
|
|
(request, context) => Task.FromResult(new CultMeshOperationReceipt(
|
|
"aetheria.entity.pilot.move",
|
|
accepted: request.EntityId == 7 &&
|
|
context.Claims.Count == 1 &&
|
|
context.Claims[0].Role == "pilot-control" &&
|
|
context.RouteHint.Kind == CultMeshLocalityKind.InProcess,
|
|
context.RouteHint)));
|
|
|
|
var receipt = await handle.InvokeAsync(
|
|
new MeshMoveRequest(7, 1, -1),
|
|
CultMeshOperationContext
|
|
.ForRuntime("unity-raven")
|
|
.WithClaim(new CultMeshAuthorityClaim("pilot-control", shardId: "zone:local-rts"))
|
|
.WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "co-located daemon"))
|
|
.WithIdempotencyKey("move-7-001"));
|
|
|
|
receipt.Accepted.Should().BeTrue();
|
|
receipt.Route.Kind.Should().Be(CultMeshLocalityKind.InProcess);
|
|
|
|
var diagnostic = CultMesh.DescribeOperationHandle(handle);
|
|
diagnostic.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
}
|
|
|
|
[Test]
|
|
public void ContextBuilders_MakeTypedHandleCallsReadLikeVerseOperations()
|
|
{
|
|
var operationContext = CultMesh.OperationContextFor("unity-raven")
|
|
.Claim("pilot-control", shardId: "zone:local-rts", leaseId: "lease:raven")
|
|
.Route(CultMeshLocalityKind.SharedMemory, "co-located daemon")
|
|
.Idempotency("move-7-001")
|
|
.Build();
|
|
|
|
operationContext.RuntimeId.Should().Be("unity-raven");
|
|
operationContext.Claims.Should().ContainSingle();
|
|
operationContext.Claims[0].Role.Should().Be("pilot-control");
|
|
operationContext.Claims[0].ShardId.Should().Be("zone:local-rts");
|
|
operationContext.Claims[0].LeaseId.Should().Be("lease:raven");
|
|
operationContext.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
operationContext.IdempotencyKey.Should().Be("move-7-001");
|
|
|
|
var queryContext = CultMesh.QueryContextFor("browser-starfire")
|
|
.Route(CultMeshLocalityKind.Wasm, "browser-local projection")
|
|
.Build();
|
|
|
|
queryContext.RuntimeId.Should().Be("browser-starfire");
|
|
queryContext.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Wasm);
|
|
queryContext.RouteHint.Description.Should().Be("browser-local projection");
|
|
}
|
|
|
|
[Test]
|
|
public async Task VerseContext_LetsGeneratedDomainSugarUseSharedTypedContexts()
|
|
{
|
|
var verse = await CultMesh.ConnectVerseAsync(
|
|
"starbridge",
|
|
"unity-raven",
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located Verse"),
|
|
new[] { new CultMeshAuthorityClaim("pilot-control", shardId: "zone:raven", leaseId: "lease:raven") });
|
|
var commandVerse = verse.WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote command route"));
|
|
|
|
var aetheria = commandVerse.Use(context => new MeshAetheriaDomain(context));
|
|
var receipt = await aetheria.Entity(7).Pilot.MoveAsync(new MeshVec2(1, 0), "move:raven:1");
|
|
var viewport = await aetheria.Zone("zone:raven").Objects.VisibleWithinAsync(new MeshViewportRequest(-16, 16));
|
|
|
|
verse.VerseId.Should().Be("starbridge");
|
|
verse.RuntimeId.Should().Be("unity-raven");
|
|
verse.QueryContext().RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
commandVerse.OperationContext("move:raven:2").RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
commandVerse.OperationContext("move:raven:2").IdempotencyKey.Should().Be("move:raven:2");
|
|
receipt.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
receipt.Accepted.Should().BeTrue();
|
|
receipt.Route.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
viewport.Should().Equal("unity-raven:zone:raven:-16:16:Network");
|
|
}
|
|
|
|
[Test]
|
|
public async Task QuerySurfaceAndStatePointer_ExposeTypedReactiveState()
|
|
{
|
|
var query = new CultMeshQuerySurface<MeshViewportRequest, string[]>(
|
|
"aetheria.zone.objects.visible",
|
|
(request, context) => Task.FromResult(new[] { $"{context.RuntimeId}:{request.MinX}:{request.MaxX}" }));
|
|
|
|
var queryResult = await query.ExecuteAsync(
|
|
new MeshViewportRequest(-8, 8),
|
|
CultMeshQueryContext.ForRuntime("browser-starfire"));
|
|
|
|
queryResult.Should().Equal("browser-starfire:-8:8");
|
|
|
|
var queryDiagnostic = CultMesh.DescribeQuerySurface(query);
|
|
queryDiagnostic.QueryId.Should().Be("aetheria.zone.objects.visible");
|
|
queryDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic);
|
|
queryDiagnostic.Sources.Should().BeEmpty();
|
|
|
|
var subject = new Subject<string>();
|
|
var pointer = CultMesh.StatePointer(
|
|
"aetheria.selection.current",
|
|
() => Task.FromResult("entity:station:0"),
|
|
() => subject,
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located selection cache"),
|
|
new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.selection.current.v1",
|
|
schemaId: "gamecult.aetheria.selection.v1")
|
|
});
|
|
|
|
string observed = null!;
|
|
using var subscription = pointer.Watch().Subscribe(value => observed = value);
|
|
|
|
(await pointer.ResolveAsync()).Should().Be("entity:station:0");
|
|
subject.OnNext("entity:pawn:7");
|
|
observed.Should().Be("entity:pawn:7");
|
|
|
|
var pointerDiagnostic = CultMesh.DescribeStatePointer(pointer);
|
|
pointerDiagnostic.PointerId.Should().Be("aetheria.selection.current");
|
|
pointerDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
pointerDiagnostic.Sources.Should().ContainSingle().Which.SchemaId.Should().Be("gamecult.aetheria.selection.v1");
|
|
}
|
|
|
|
[Test]
|
|
public async Task StatePointer_CanBindToVerseContextForUiAndTools()
|
|
{
|
|
var subject = new Subject<string>();
|
|
var pointer = CultMesh.StatePointer(
|
|
"aetheria.daemon.frame.latest",
|
|
context => Task.FromResult($"{context.RuntimeId}:{context.RouteHint.Kind}"),
|
|
context => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"),
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located daemon frame"),
|
|
new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.frame.latest.v1",
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1")
|
|
});
|
|
|
|
var verse = CultMesh.Verse(
|
|
"aetheria.local",
|
|
"bifrost-tool",
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge"));
|
|
var bound = verse.BindStatePointer(pointer);
|
|
|
|
(await bound.ResolveAsync()).Should().Be("bifrost-tool:Ipc");
|
|
|
|
string observed = null!;
|
|
using var subscription = bound.Watch().Subscribe(value => observed = value);
|
|
subject.OnNext("frame:12");
|
|
|
|
bound.PointerId.Should().Be("aetheria.daemon.frame.latest");
|
|
bound.Sources.Should().ContainSingle().Which.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
observed.Should().Be("bifrost-tool:frame:12:Ipc");
|
|
}
|
|
|
|
[Test]
|
|
public async Task MutableStatePointer_HoistsReadWatchReplaceDocumentHandles()
|
|
{
|
|
var subject = new Subject<string>();
|
|
var stored = "frame:0";
|
|
var observedContexts = new List<string>();
|
|
var pointer = CultMesh.MutableStatePointer(
|
|
"aetheria.daemon.frame.latest",
|
|
context =>
|
|
{
|
|
observedContexts.Add($"read:{context.RuntimeId}:{context.RouteHint.Kind}");
|
|
return Task.FromResult<string?>(stored);
|
|
},
|
|
context => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"),
|
|
(context, value) =>
|
|
{
|
|
observedContexts.Add($"replace:{context.RuntimeId}:{context.RouteHint.Kind}");
|
|
stored = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located daemon frame"),
|
|
new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.frame.latest.v1",
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1")
|
|
});
|
|
|
|
var verse = CultMesh.Verse(
|
|
"aetheria.local",
|
|
"unity-raven",
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge"));
|
|
var bound = verse.BindMutableStatePointer(pointer);
|
|
|
|
string observed = null!;
|
|
using var subscription = bound.Watch().Subscribe(value => observed = value);
|
|
|
|
(await bound.ReadAsync()).Should().Be("frame:0");
|
|
await bound.ReplaceAsync("frame:1");
|
|
|
|
stored.Should().Be("frame:1");
|
|
observed.Should().Be("unity-raven:frame:1:Ipc");
|
|
observedContexts.Should().Equal("read:unity-raven:Ipc", "replace:unity-raven:Ipc");
|
|
|
|
var diagnostic = CultMesh.DescribeStatePointer(pointer);
|
|
diagnostic.PointerId.Should().Be("aetheria.daemon.frame.latest");
|
|
diagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
var binding = CultMesh.StateBinding("frame", pointer);
|
|
binding.PointerId.Should().Be("aetheria.daemon.frame.latest");
|
|
binding.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
}
|
|
|
|
[Test]
|
|
public void StateBindingDescriptor_BindsUiPropsToTypedStatePointers()
|
|
{
|
|
var pointer = CultMesh.StatePointer(
|
|
"aetheria.selection.current",
|
|
() => Task.FromResult("entity:station:0"),
|
|
() => new Subject<string>(),
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located selection cache"),
|
|
new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.selection.current.v1",
|
|
schemaId: "gamecult.aetheria.selection.v1")
|
|
});
|
|
|
|
var binding = CultMesh.StateBinding("value", pointer);
|
|
var explicitBinding = CultMesh.StateBinding(
|
|
"label",
|
|
"aetheria.selection.label",
|
|
sourceId: "daemon:aetheria.selection.label.v1",
|
|
schemaId: "gamecult.aetheria.selection_label.v1",
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge"));
|
|
|
|
binding.TargetProp.Should().Be("value");
|
|
binding.PointerId.Should().Be("aetheria.selection.current");
|
|
binding.SourceId.Should().Be("daemon:aetheria.selection.current.v1");
|
|
binding.SchemaId.Should().Be("gamecult.aetheria.selection.v1");
|
|
binding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
binding.RouteHint.Description.Should().Be("co-located selection cache");
|
|
|
|
explicitBinding.TargetProp.Should().Be("label");
|
|
explicitBinding.PointerId.Should().Be("aetheria.selection.label");
|
|
explicitBinding.SourceId.Should().Be("daemon:aetheria.selection.label.v1");
|
|
explicitBinding.SchemaId.Should().Be("gamecult.aetheria.selection_label.v1");
|
|
explicitBinding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc);
|
|
}
|
|
|
|
[Test]
|
|
public void StateBindingRecord_FlattensAndRehydratesPointerBindingFields()
|
|
{
|
|
var binding = CultMesh.StateBinding(
|
|
"status",
|
|
"aetheria.current.status",
|
|
sourceId: "daemon:aetheria.frame.latest.v1",
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1",
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab"));
|
|
|
|
var record = CultMesh.StateBindingRecord(binding);
|
|
record.TargetProp.Should().Be("status");
|
|
record.PointerId.Should().Be("aetheria.current.status");
|
|
record.SourceId.Should().Be("daemon:aetheria.frame.latest.v1");
|
|
record.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
record.RouteKind.Should().Be(nameof(CultMeshLocalityKind.SharedMemory));
|
|
record.RouteDescription.Should().Be("co-located frame slab");
|
|
|
|
var fromTypescript = CultMesh.StateBindingRecord(
|
|
"value",
|
|
"aetheria.selection.current",
|
|
"daemon:aetheria.selection.current.v1",
|
|
"gamecult.aetheria.selection.v1",
|
|
"shared-memory",
|
|
"browser-adjacent cache").ToBinding();
|
|
|
|
fromTypescript.TargetProp.Should().Be("value");
|
|
fromTypescript.PointerId.Should().Be("aetheria.selection.current");
|
|
fromTypescript.SourceId.Should().Be("daemon:aetheria.selection.current.v1");
|
|
fromTypescript.SchemaId.Should().Be("gamecult.aetheria.selection.v1");
|
|
fromTypescript.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
fromTypescript.RouteHint.Description.Should().Be("browser-adjacent cache");
|
|
}
|
|
|
|
[Test]
|
|
public void StateBindingRecord_PreservesEditableFieldIdentityAndAuthority()
|
|
{
|
|
var binding = CultMesh.StateBinding(
|
|
"value",
|
|
"ghostlight.session-zero.composer",
|
|
sourceId: "session-zero:draft-7",
|
|
schemaId: "session_zero.v1",
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "Ghostlight provider"),
|
|
bindingName: "composer.message",
|
|
documentId: "session-zero:draft-7",
|
|
fieldPath: "channels.private.messages.pending",
|
|
valueKind: "string",
|
|
accessMode: "local-draft",
|
|
authority: "SessionZeroKernel",
|
|
writeCommand: "edit.value");
|
|
|
|
var record = CultMesh.StateBindingRecord(binding);
|
|
record.BindingName.Should().Be("composer.message");
|
|
record.FieldPath.Should().Be("channels.private.messages.pending");
|
|
record.AccessMode.Should().Be("local-draft");
|
|
var restored = record.ToBinding();
|
|
restored.BindingName.Should().Be(binding.BindingName);
|
|
restored.DocumentId.Should().Be(binding.DocumentId);
|
|
restored.FieldPath.Should().Be(binding.FieldPath);
|
|
restored.ValueKind.Should().Be(binding.ValueKind);
|
|
restored.AccessMode.Should().Be(binding.AccessMode);
|
|
restored.Authority.Should().Be(binding.Authority);
|
|
restored.WriteCommand.Should().Be(binding.WriteCommand);
|
|
}
|
|
|
|
[Test]
|
|
public void OperationBindingDescriptor_BindsUiCommandsToTypedOperations()
|
|
{
|
|
var operation = new CultMeshOperationHandle<MeshMoveRequest, CultMeshOperationReceipt>(
|
|
"aetheria.entity.pilot.move",
|
|
(_request, _context) => Task.FromResult(new CultMeshOperationReceipt("aetheria.entity.pilot.move", true)));
|
|
|
|
var binding = CultMesh.OperationBinding(
|
|
operation,
|
|
label: "Move",
|
|
schemaId: "gamecult.aetheria.pilot_move.v1",
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer"));
|
|
var explicitBinding = CultMesh.OperationBinding(
|
|
"aetheria.surface.refresh",
|
|
label: "Refresh");
|
|
|
|
binding.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
binding.Label.Should().Be("Move");
|
|
binding.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
binding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
binding.RouteHint.Description.Should().Be("remote Verse peer");
|
|
|
|
explicitBinding.OperationId.Should().Be("aetheria.surface.refresh");
|
|
explicitBinding.Label.Should().Be("Refresh");
|
|
explicitBinding.SchemaId.Should().Be("");
|
|
explicitBinding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic);
|
|
}
|
|
|
|
[Test]
|
|
public void OperationBindingRecord_FlattensAndRehydratesSurfaceCommandFields()
|
|
{
|
|
var binding = CultMesh.OperationBinding(
|
|
"aetheria.entity.pilot.move",
|
|
label: "Move",
|
|
schemaId: "gamecult.aetheria.pilot_move.v1",
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer"));
|
|
|
|
var record = CultMesh.OperationBindingRecord(binding);
|
|
record.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
record.Label.Should().Be("Move");
|
|
record.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
record.RouteKind.Should().Be(nameof(CultMeshLocalityKind.Network));
|
|
record.RouteDescription.Should().Be("remote Verse peer");
|
|
|
|
var fromTypescript = CultMesh.OperationBindingRecord(
|
|
"aetheria.surface.refresh",
|
|
"Refresh",
|
|
"gamecult.aetheria.refresh.v1",
|
|
"in-process",
|
|
"embedded UI runtime").ToBinding();
|
|
|
|
fromTypescript.OperationId.Should().Be("aetheria.surface.refresh");
|
|
fromTypescript.Label.Should().Be("Refresh");
|
|
fromTypescript.SchemaId.Should().Be("gamecult.aetheria.refresh.v1");
|
|
fromTypescript.RouteHint.Kind.Should().Be(CultMeshLocalityKind.InProcess);
|
|
fromTypescript.RouteHint.Description.Should().Be("embedded UI runtime");
|
|
}
|
|
|
|
[Test]
|
|
public void OperationInvocationDescriptor_CarriesTypedOperationIdentityThroughUiRequests()
|
|
{
|
|
var binding = CultMesh.OperationBinding(
|
|
"aetheria.entity.pilot.move",
|
|
label: "Move",
|
|
schemaId: "gamecult.aetheria.pilot_move.v1",
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "local Verse node"));
|
|
|
|
var invocation = CultMesh.OperationInvocation(binding, idempotencyKey: "move:42");
|
|
var explicitInvocation = CultMesh.OperationInvocation(
|
|
"aetheria.surface.refresh",
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer"));
|
|
|
|
invocation.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
invocation.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
invocation.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc);
|
|
invocation.RouteHint.Description.Should().Be("local Verse node");
|
|
invocation.IdempotencyKey.Should().Be("move:42");
|
|
|
|
explicitInvocation.OperationId.Should().Be("aetheria.surface.refresh");
|
|
explicitInvocation.SchemaId.Should().Be("");
|
|
explicitInvocation.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
explicitInvocation.IdempotencyKey.Should().BeNull();
|
|
}
|
|
|
|
[Test]
|
|
public void OperationPayload_ReadsCommonSurfaceScalarFields()
|
|
{
|
|
var payload = CultMesh.OperationPayload(
|
|
("value", "42.5"),
|
|
("tierIndex", "3"),
|
|
("enabled", "on"),
|
|
("name", "Starfire"));
|
|
var updated = payload.With("enabled", "false");
|
|
|
|
payload.GetString("name").Should().Be("Starfire");
|
|
payload.GetString("missing", "fallback").Should().Be("fallback");
|
|
payload.GetInt32("tierIndex", -1).Should().Be(3);
|
|
payload.GetInt32("missing", -1).Should().Be(-1);
|
|
payload.GetDouble("value", -1).Should().Be(42.5);
|
|
payload.GetBoolean("enabled").Should().BeTrue();
|
|
updated.GetBoolean("enabled", true).Should().BeFalse();
|
|
updated.GetString("name").Should().Be("Starfire");
|
|
payload.GetBoolean("missing", true).Should().BeTrue();
|
|
payload.Should().ContainKey("value");
|
|
payload.Count.Should().Be(4);
|
|
}
|
|
|
|
[Test]
|
|
public void OperationInvocationRecord_FlattensAndRehydratesTransportFields()
|
|
{
|
|
var invocation = CultMesh.OperationInvocation(
|
|
"aetheria.entity.pilot.move",
|
|
"gamecult.aetheria.pilot_move.v1",
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "local Verse node"),
|
|
"move:42");
|
|
|
|
var record = CultMesh.OperationInvocationRecord(invocation);
|
|
var restored = record.ToInvocation(
|
|
fallbackOperationId: "fallback.operation",
|
|
fallbackSchemaId: "fallback.schema",
|
|
fallbackRouteHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route"));
|
|
var fallback = CultMesh.OperationInvocationRecord(
|
|
operationId: "",
|
|
schemaId: "",
|
|
routeKind: "not-a-route",
|
|
routeDescription: "",
|
|
idempotencyKey: "")
|
|
.ToInvocation(
|
|
fallbackOperationId: "fallback.operation",
|
|
fallbackSchemaId: "fallback.schema",
|
|
fallbackRouteHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route"),
|
|
fallbackIdempotencyKey: "fallback-key");
|
|
var payloadFields = CultMesh.OperationPayload(("value", "Raven")).ToDictionary();
|
|
|
|
record.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
record.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
record.RouteKind.Should().Be("Ipc");
|
|
record.RouteDescription.Should().Be("local Verse node");
|
|
record.IdempotencyKey.Should().Be("move:42");
|
|
restored.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
restored.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
restored.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc);
|
|
restored.IdempotencyKey.Should().Be("move:42");
|
|
fallback.OperationId.Should().Be("fallback.operation");
|
|
fallback.SchemaId.Should().Be("fallback.schema");
|
|
fallback.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
fallback.RouteHint.Description.Should().Be("fallback route");
|
|
fallback.IdempotencyKey.Should().Be("fallback-key");
|
|
payloadFields["value"].Should().Be("Raven");
|
|
}
|
|
|
|
[Test]
|
|
public void OperationInvocationRecord_ReadsLegacyStringAndWritesStructuredRecord()
|
|
{
|
|
var legacyPayload = MessagePackSerializer.Serialize(
|
|
"aetheria.entity.pilot.move",
|
|
CultDocumentMessagePackSerialization.Options);
|
|
var restored = MessagePackSerializer.Deserialize<CultMeshOperationInvocationRecord>(
|
|
legacyPayload,
|
|
CultDocumentMessagePackSerialization.Options);
|
|
var currentPayload = MessagePackSerializer.Serialize(
|
|
restored,
|
|
CultDocumentMessagePackSerialization.Options);
|
|
var reader = new MessagePackReader(currentPayload);
|
|
|
|
restored.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
restored.SchemaId.Should().BeEmpty();
|
|
restored.RouteKind.Should().BeEmpty();
|
|
restored.IdempotencyKey.Should().BeEmpty();
|
|
reader.ReadArrayHeader().Should().Be(5);
|
|
reader.ReadString().Should().Be("aetheria.entity.pilot.move");
|
|
}
|
|
|
|
[Test]
|
|
public void RouteRecord_ReadsLegacyStringAndWritesStructuredRecord()
|
|
{
|
|
var legacyPayload = MessagePackSerializer.Serialize(
|
|
"SharedMemory",
|
|
CultDocumentMessagePackSerialization.Options);
|
|
var restored = MessagePackSerializer.Deserialize<CultMeshRouteRecord>(
|
|
legacyPayload,
|
|
CultDocumentMessagePackSerialization.Options);
|
|
var currentPayload = MessagePackSerializer.Serialize(
|
|
restored,
|
|
CultDocumentMessagePackSerialization.Options);
|
|
var reader = new MessagePackReader(currentPayload);
|
|
|
|
restored.Kind.Should().Be("SharedMemory");
|
|
restored.Description.Should().BeEmpty();
|
|
reader.ReadArrayHeader().Should().Be(2);
|
|
reader.ReadString().Should().Be("SharedMemory");
|
|
}
|
|
|
|
[Test]
|
|
public void RouteRecord_FlattensAndParsesCrossRuntimeRouteKinds()
|
|
{
|
|
var record = CultMesh.RouteRecord(new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located slab"));
|
|
var restored = record.ToRoute();
|
|
var fromTypescript = CultMesh.RouteRecord("in-process", "").ToRoute(
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route"));
|
|
var fallback = CultMesh.RouteRecord("not-real", "").ToRoute(
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "fallback route"));
|
|
|
|
record.Kind.Should().Be("SharedMemory");
|
|
record.Description.Should().Be("co-located slab");
|
|
restored.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
restored.Description.Should().Be("co-located slab");
|
|
fromTypescript.Kind.Should().Be(CultMeshLocalityKind.InProcess);
|
|
fromTypescript.Description.Should().Be("fallback route");
|
|
fallback.Kind.Should().Be(CultMeshLocalityKind.Ipc);
|
|
fallback.Description.Should().Be("fallback route");
|
|
}
|
|
|
|
[Test]
|
|
public void StateRefResolver_ComposesNamedSurfaceResolution()
|
|
{
|
|
var route = new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "embedded renderer");
|
|
var daemon = CultMesh.StateRefResolver(
|
|
"aetheria.daemon.refs",
|
|
(stateRef, context) => stateRef == "aetheria.daemon/frame/id"
|
|
? $"{context.RuntimeId}:{context.RouteHint.Kind}:42"
|
|
: "",
|
|
new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.frame.latest.v1",
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1")
|
|
},
|
|
route);
|
|
var itemStats = CultMesh.StateRefResolver(
|
|
"aetheria.item_stats.refs",
|
|
stateRef => stateRef == "aetheria.item_stats/laser/damage" ? "12.5" : "");
|
|
var resolver = daemon.Or(itemStats);
|
|
|
|
resolver.Resolve(
|
|
"aetheria.daemon/frame/id",
|
|
CultMesh.QueryContextFor("unity-raven")
|
|
.Route(CultMeshLocalityKind.Network, "remote peer")
|
|
.Build())
|
|
.Should()
|
|
.Be("unity-raven:Network:42");
|
|
resolver.Resolve("aetheria.item_stats/laser/damage").Should().Be("12.5");
|
|
resolver.TryResolve("missing", out var missing).Should().BeFalse();
|
|
missing.Should().Be("");
|
|
resolver.AsFunc()("aetheria.item_stats/laser/damage").Should().Be("12.5");
|
|
|
|
var diagnostic = CultMesh.DescribeStateRefResolver(resolver);
|
|
diagnostic.ResolverId.Should().Be("aetheria.daemon.refs|aetheria.item_stats.refs");
|
|
diagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.InProcess);
|
|
diagnostic.Sources.Should().ContainSingle();
|
|
diagnostic.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ProjectionRecipe_NamesSourcesAndCanBecomeQuerySurface()
|
|
{
|
|
var recipe = CultMesh.ProjectionRecipe<MeshViewportRequest, string[]>(
|
|
"aetheria.zone.objects.visible",
|
|
new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.frame.latest.v1",
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1",
|
|
description: "latest daemon frame"),
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.authority.policy.v1",
|
|
schemaId: "gamecult.aetheria.authority_policy.v1")
|
|
},
|
|
(request, context) => Task.FromResult(new[]
|
|
{
|
|
$"{context.RuntimeId}:{request.MinX}:{request.MaxX}:{context.RouteHint.Kind}"
|
|
}),
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab"));
|
|
|
|
recipe.Sources.Should().HaveCount(2);
|
|
recipe.Sources[0].SourceId.Should().Be("daemon:aetheria.frame.latest.v1");
|
|
recipe.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
var recipeDiagnostic = CultMesh.DescribeProjectionRecipe(recipe);
|
|
recipeDiagnostic.ProjectionId.Should().Be("aetheria.zone.objects.visible");
|
|
recipeDiagnostic.RouteHint.Description.Should().Be("co-located frame slab");
|
|
recipeDiagnostic.Sources.Should().HaveCount(2);
|
|
recipeDiagnostic.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
recipeDiagnostic.Sources.Should().NotBeSameAs(recipe.Sources);
|
|
|
|
var result = await recipe.ProjectAsync(
|
|
new MeshViewportRequest(-16, 16),
|
|
CultMesh.QueryContextFor("browser-starfire")
|
|
.Route(CultMeshLocalityKind.Wasm, "browser-local projection")
|
|
.Build());
|
|
|
|
result.Should().Equal("browser-starfire:-16:16:Wasm");
|
|
|
|
var query = recipe.AsQuerySurface();
|
|
var queryResult = await query.ExecuteAsync(
|
|
new MeshViewportRequest(-4, 4),
|
|
"unity-raven");
|
|
|
|
query.QueryId.Should().Be(recipe.ProjectionId);
|
|
query.Sources.Should().HaveCount(2);
|
|
query.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
query.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
queryResult.Should().Equal("unity-raven:-4:4:SharedMemory");
|
|
|
|
var queryDiagnostic = CultMesh.DescribeQuerySurface(query);
|
|
queryDiagnostic.QueryId.Should().Be("aetheria.zone.objects.visible");
|
|
queryDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
queryDiagnostic.Sources.Should().HaveCount(2);
|
|
queryDiagnostic.Sources.Should().NotBeSameAs(query.Sources);
|
|
}
|
|
|
|
[Test]
|
|
public async Task LiveFeed_DescribesAndWatchesCoherentClientSnapshots()
|
|
{
|
|
var subject = new Subject<string>();
|
|
var feed = CultMesh.LiveFeed<MeshViewportRequest, string>(
|
|
"aetheria.rts.viewport.feed",
|
|
(request, context) => Task.FromResult(
|
|
$"{context.RuntimeId}:{request.MinX}:{request.MaxX}:{context.RouteHint.Kind}"),
|
|
(_request, context) => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"),
|
|
new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.frame.latest.v1",
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1"),
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.health.latest.v1",
|
|
schemaId: "gamecult.aetheria.daemon_health.v1")
|
|
},
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located RTS cache"));
|
|
|
|
feed.FeedId.Should().Be("aetheria.rts.viewport.feed");
|
|
feed.Sources.Should().HaveCount(2);
|
|
feed.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
feed.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
var diagnostic = CultMesh.DescribeLiveFeed(feed);
|
|
diagnostic.FeedId.Should().Be("aetheria.rts.viewport.feed");
|
|
diagnostic.RouteHint.Description.Should().Be("co-located RTS cache");
|
|
diagnostic.Sources.Should().HaveCount(2);
|
|
diagnostic.Sources[1].SourceId.Should().Be("daemon:aetheria.health.latest.v1");
|
|
diagnostic.Sources.Should().NotBeSameAs(feed.Sources);
|
|
|
|
var inherited = await feed.SnapshotAsync(
|
|
new MeshViewportRequest(-32, 32),
|
|
"browser-starfire");
|
|
|
|
inherited.Should().Be("browser-starfire:-32:32:SharedMemory");
|
|
|
|
var explicitRoute = await feed.SnapshotAsync(
|
|
new MeshViewportRequest(-32, 32),
|
|
CultMesh.QueryContextFor("unity-raven")
|
|
.Route(CultMeshLocalityKind.InProcess, "embedded Verse")
|
|
.Build());
|
|
|
|
explicitRoute.Should().Be("unity-raven:-32:32:InProcess");
|
|
|
|
string observed = null!;
|
|
using var subscription = feed
|
|
.Watch(new MeshViewportRequest(-1, 1), CultMeshQueryContext.ForRuntime("browser-starfire"))
|
|
.Subscribe(value => observed = value);
|
|
|
|
subject.OnNext("frame:42");
|
|
observed.Should().Be("browser-starfire:frame:42:SharedMemory");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentHandle_ExposesTypedSnapshotsAndWatches()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located document slab");
|
|
var sources = new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:mesh.note.current",
|
|
schemaId: "tests.mesh_note.v1")
|
|
};
|
|
var context = CultMesh.Verse("starbridge", "unity-pilot", route).Context;
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "primary",
|
|
Revision = 1
|
|
};
|
|
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.current",
|
|
context,
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
sources,
|
|
route);
|
|
|
|
handle.DocumentId.Should().Be("mesh.note.current");
|
|
handle.DocumentType.Should().Be(typeof(MeshNoteDocument));
|
|
handle.SchemaName.Should().Be("tests.mesh_note");
|
|
handle.SchemaVersion.Should().Be("tests.mesh_note.v1");
|
|
handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:mesh.note.current");
|
|
|
|
var snapshot = await handle.LatestAsync();
|
|
snapshot.Text.Should().Be("primary");
|
|
handle.Latest().Text.Should().Be("primary");
|
|
|
|
MeshNoteDocument observed = null!;
|
|
using var subscription = handle.Watch(value => observed = value);
|
|
subject.OnNext(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "reactive",
|
|
Revision = 2
|
|
});
|
|
|
|
observed.Text.Should().Be("reactive");
|
|
observed.Revision.Should().Be(2);
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentHandle_Field_ProjectsTypedReactiveStateWithoutRepublishingSurfaceTrees()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located document slab");
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.current",
|
|
CultMesh.Verse("starbridge", "unity-pilot", route),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
routeHint: route);
|
|
|
|
var text = handle.Field(value => value.Text);
|
|
|
|
text.PointerId.Should().Be("mesh.note.current#Text");
|
|
text.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
(await text.ResolveAsync()).Should().Be("initial");
|
|
string observed = null!;
|
|
using var subscription = text.Watch().Subscribe(value => observed = value);
|
|
subject.OnNext(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "reactive",
|
|
Revision = 2
|
|
});
|
|
observed.Should().Be("reactive");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentCatalog_IndexesHandlesByTypeAndSchema()
|
|
{
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "catalog-primary",
|
|
Revision = 3
|
|
};
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.current",
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
_ => Task.FromResult(current),
|
|
_ => new Subject<MeshNoteDocument>());
|
|
var catalog = CultMesh.Documents(handle);
|
|
|
|
catalog.Documents.Should().ContainSingle().Which.Should().BeSameAs(handle);
|
|
catalog.TryGetDocument<MeshNoteDocument>(out var exact).Should().BeTrue();
|
|
exact.Should().BeSameAs(handle);
|
|
catalog.TryGetDocumentBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue();
|
|
byVersion.Should().BeSameAs(handle);
|
|
catalog.DocumentBySchema("tests.mesh_note").Should().BeSameAs(handle);
|
|
catalog.DocumentBySchema(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId)
|
|
.Should()
|
|
.BeSameAs(handle);
|
|
|
|
catalog.TryGetDocument<MeshOtherDocument>(out _).Should().BeFalse();
|
|
|
|
var snapshot = await catalog.LatestAsync<MeshNoteDocument>();
|
|
snapshot.Text.Should().Be("catalog-primary");
|
|
snapshot.Revision.Should().Be(3);
|
|
catalog.Latest<MeshNoteDocument>().Text.Should().Be("catalog-primary");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentHelpers_ProjectLatestSnapshotsFromMultipleHandles()
|
|
{
|
|
var primary = CultMesh.Document(
|
|
"mesh.note.primary",
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
_ => Task.FromResult(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "alpha",
|
|
Revision = 3
|
|
}),
|
|
_ => new Subject<MeshNoteDocument>());
|
|
var secondary = CultMesh.Document(
|
|
"mesh.note.secondary",
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
_ => Task.FromResult(new MeshOtherDocument
|
|
{
|
|
Schema = "tests.mesh_other.v1",
|
|
Text = "beta"
|
|
}),
|
|
_ => new Subject<MeshOtherDocument>());
|
|
|
|
var projected = await CultMesh.LatestAsync(
|
|
primary,
|
|
secondary,
|
|
(first, second) => $"{first.Text}:{first.Revision}:{second.Text}");
|
|
|
|
projected.Should().Be("alpha:3:beta");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentHandle_ReadsWatchesAndReplacesCultCacheRecords()
|
|
{
|
|
var cache = new CultCache();
|
|
var key = new CultRecordKey("mesh-note:cache");
|
|
await cache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "cache-initial",
|
|
Revision = 1
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
var handle = CultMesh.Document<MeshNoteDocument>(
|
|
cache,
|
|
key,
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
handle.CanReplace.Should().BeTrue();
|
|
handle.CanSubmitPrediction.Should().BeFalse();
|
|
handle.DocumentId.Should().Be(key.Value);
|
|
handle.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(handle.SchemaId);
|
|
|
|
var snapshot = await handle.LatestAsync();
|
|
snapshot.Text.Should().Be("cache-initial");
|
|
|
|
MeshNoteDocument observed = null!;
|
|
using var subscription = handle.Watch(value => observed = value);
|
|
await handle.ReplaceAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "cache-replaced",
|
|
Revision = 2
|
|
});
|
|
|
|
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("cache-replaced");
|
|
observed.Text.Should().Be("cache-replaced");
|
|
observed.Revision.Should().Be(2);
|
|
|
|
var catalog = CultMesh.Documents(handle);
|
|
catalog.CanReplace<MeshNoteDocument>().Should().BeTrue();
|
|
await catalog.ReplaceAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "cache-catalog-replaced",
|
|
Revision = 3
|
|
});
|
|
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("cache-catalog-replaced");
|
|
|
|
Func<Task> act = () => handle.SubmitPredictionAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "cache-predicted",
|
|
Revision = 4
|
|
});
|
|
await act.Should().ThrowAsync<NotSupportedException>();
|
|
}
|
|
|
|
// A database serves only what its shards own. A schema has one CLR type (retire-schema-alias), so there is no second type
|
|
// that could reach a record around the shard that owns its schema.
|
|
[TestCase("tests.mesh_note.v1", true)]
|
|
[TestCase("tests.unrelated.v1", false)]
|
|
public async Task DocumentHandle_ReadsARecordOnlyWhenAShardOwnsItsSchema(string ownedSchemaId, bool served)
|
|
{
|
|
var cache = new CultCache();
|
|
var key = new CultRecordKey("note:one");
|
|
await cache.UpsertAsync(new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "cached" }, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
|
{
|
|
RuntimeId = "reader",
|
|
Shards = new[] { new CultNetShardDescriptor("notes", "reader", epoch: 1, isPrimary: true, schemaIds: new[] { ownedSchemaId }) }
|
|
});
|
|
var handle = CultMesh.Document<MeshNoteDocument>(database, key, CultMesh.Verse("starbridge", "reader"));
|
|
|
|
if (served)
|
|
{
|
|
(await handle.LatestAsync()).Text.Should().Be("cached");
|
|
}
|
|
else
|
|
{
|
|
await FluentActions.Awaiting(() => handle.LatestAsync()).Should().ThrowAsync<KeyNotFoundException>();
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentHandle_SubmitsPredictionsThroughCultNetDatabase()
|
|
{
|
|
var cache = new CultCache();
|
|
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
|
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
|
{
|
|
RuntimeId = "pilot-a",
|
|
Shards = new[]
|
|
{
|
|
new CultNetShardDescriptor(
|
|
"pilot-inputs",
|
|
"server",
|
|
epoch: 1,
|
|
isPrimary: false,
|
|
schemaIds: new[] { schemaId },
|
|
keyPrefix: "input:")
|
|
},
|
|
ClientAuthorityScopes = new[]
|
|
{
|
|
new CultNetClientAuthorityScope(
|
|
"pilot-a",
|
|
schemaIds: new[] { schemaId },
|
|
keyPrefix: "input:pilot-a")
|
|
}
|
|
});
|
|
var key = new CultRecordKey("input:pilot-a:thermal");
|
|
|
|
var handle = CultMesh.Document<MeshNoteDocument>(
|
|
database,
|
|
key,
|
|
CultMesh.Verse("starbridge", "pilot-a"));
|
|
|
|
handle.CanReplace.Should().BeFalse();
|
|
handle.CanSubmitPrediction.Should().BeTrue();
|
|
var catalog = CultMesh.Documents(handle);
|
|
catalog.CanSubmitPrediction<MeshNoteDocument>().Should().BeTrue();
|
|
var predictionWriter = catalog.PredictionWriter<MeshNoteDocument>();
|
|
predictionWriter.Kind.Should().Be(CultMeshDocumentWriteKind.Prediction);
|
|
|
|
MeshNoteDocument observed = null!;
|
|
using var subscription = handle.Watch(value => observed = value);
|
|
await predictionWriter.WriteAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "predicted-thermal",
|
|
Revision = 5
|
|
});
|
|
|
|
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("predicted-thermal");
|
|
observed.Text.Should().Be("predicted-thermal");
|
|
observed.Revision.Should().Be(5);
|
|
(await handle.LatestAsync()).Text.Should().Be("predicted-thermal");
|
|
|
|
var updated = await handle.PredictionWriter().UpdateAsync(value => new MeshNoteDocument
|
|
{
|
|
Schema = value.Schema,
|
|
Text = "updated-as-prediction",
|
|
Revision = value.Revision + 1
|
|
});
|
|
updated.Text.Should().Be("updated-as-prediction");
|
|
cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(6);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_CoalescesLocalEditsIntoOnePrediction()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
predictions.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
var catalog = CultMesh.Documents(handle);
|
|
|
|
using var reactive = await catalog.PredictionWriter<MeshNoteDocument>().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
|
|
reactive.Snapshot.Text.Should().Be("initial");
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "first-local-edit";
|
|
document.Revision++;
|
|
});
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "second-local-edit";
|
|
document.Revision++;
|
|
});
|
|
|
|
predictions.Should().BeEmpty();
|
|
|
|
await reactive.FlushAsync();
|
|
|
|
predictions.Should().ContainSingle();
|
|
predictions[0].Text.Should().Be("second-local-edit");
|
|
predictions[0].Revision.Should().Be(3);
|
|
current.Text.Should().Be("second-local-edit");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_TracksCanonicalSnapshotDuringDirtyPrediction()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reconciliation",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "local-prediction";
|
|
document.Revision = 2;
|
|
});
|
|
|
|
subject.OnNext(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "canonical-correction",
|
|
Revision = 7
|
|
});
|
|
|
|
reactive.Snapshot.Text.Should().Be("local-prediction");
|
|
reactive.Reconciliation.Should().NotBeNull();
|
|
reactive.Reconciliation!.Version.Should().Be(1);
|
|
reactive.Reconciliation!.Canonical.Text.Should().Be("canonical-correction");
|
|
reactive.Reconciliation.Predicted.Text.Should().Be("local-prediction");
|
|
reactive.Reconciliation.Delta.Should().ContainKey(nameof(MeshNoteDocument.Text));
|
|
reactive.Reconciliation.Delta[nameof(MeshNoteDocument.Text)].Should().Be("local-prediction");
|
|
reactive.Reconciliation.Delta.Should().ContainKey(nameof(MeshNoteDocument.Revision));
|
|
reactive.Reconciliation.Delta[nameof(MeshNoteDocument.Revision)].Should().Be(-5.0);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_AutoFlushesOnceAfterDebounceWindow()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.debounce",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
predictions.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMilliseconds(25)
|
|
});
|
|
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "coalesced-one";
|
|
document.Revision++;
|
|
});
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "coalesced-two";
|
|
document.Revision++;
|
|
});
|
|
|
|
await WaitForAsync(() => predictions.Count == 1);
|
|
|
|
predictions[0].Text.Should().Be("coalesced-two");
|
|
predictions[0].Revision.Should().Be(3);
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_CoalescesExplicitMemberUpdates()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.direct-members",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
predictions.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMilliseconds(25)
|
|
});
|
|
|
|
reactive.Update(document => document.Text = "explicit-member-one");
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "explicit-member-two";
|
|
document.Revision = 2;
|
|
});
|
|
|
|
await WaitForAsync(() => predictions.Count == 1);
|
|
|
|
predictions[0].Text.Should().Be("explicit-member-two");
|
|
predictions[0].Revision.Should().Be(2);
|
|
current.Text.Should().Be("explicit-member-two");
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_QueuesExplicitEditsWhileFlushIsInFlight()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var firstPredictionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var releaseFirstPrediction = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.in-flight",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
async value =>
|
|
{
|
|
predictions.Add(value);
|
|
if (predictions.Count == 1)
|
|
{
|
|
firstPredictionStarted.SetResult();
|
|
await releaseFirstPrediction.Task.ConfigureAwait(false);
|
|
}
|
|
|
|
current = value;
|
|
subject.OnNext(value);
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "first-in-flight";
|
|
document.Revision = 2;
|
|
});
|
|
var flushTask = reactive.FlushAsync();
|
|
await firstPredictionStarted.Task;
|
|
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "second-after-flight-started";
|
|
document.Revision = 3;
|
|
});
|
|
releaseFirstPrediction.SetResult();
|
|
|
|
await flushTask;
|
|
|
|
predictions.Should().HaveCount(2);
|
|
predictions.Select(document => document.Text).Should().Equal(
|
|
"first-in-flight",
|
|
"second-after-flight-started");
|
|
current.Text.Should().Be("second-after-flight-started");
|
|
current.Revision.Should().Be(3);
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_UsesReplacementWhenPredictionIsUnavailable()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var replacements = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.replace",
|
|
CultMesh.Verse("starbridge", "daemon"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
replacements.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "authoritative-replace";
|
|
document.Revision = 10;
|
|
});
|
|
await reactive.FlushAsync();
|
|
|
|
replacements.Should().ContainSingle();
|
|
replacements[0].Text.Should().Be("authoritative-replace");
|
|
current.Revision.Should().Be(10);
|
|
}
|
|
|
|
[Test]
|
|
public void ReactiveDocument_ReadOnlyHandleRejectsWriterSelection()
|
|
{
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.readonly",
|
|
CultMesh.Verse("starbridge", "observer"),
|
|
_ => Task.FromResult(current),
|
|
_ => new Subject<MeshNoteDocument>());
|
|
|
|
Action authoritative = () => handle.AuthoritativeWriter();
|
|
Action prediction = () => handle.PredictionWriter();
|
|
|
|
authoritative.Should().Throw<NotSupportedException>();
|
|
prediction.Should().Throw<NotSupportedException>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ObservedDocument_WatchesWithoutWriteCapabilityOrIdleTimer()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.observed.readonly",
|
|
CultMesh.Verse("starbridge", "observer"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject);
|
|
|
|
using var observed = await handle.ObserveAsync();
|
|
observed.Current.Text.Should().Be("initial");
|
|
typeof(CultMeshObservedDocument<MeshNoteDocument>)
|
|
.GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
|
|
.Should()
|
|
.NotContain(field => field.FieldType == typeof(System.Threading.Timer));
|
|
|
|
subject.OnNext(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "canonical-update",
|
|
Revision = 2
|
|
});
|
|
|
|
observed.Current.Text.Should().Be("canonical-update");
|
|
observed.Current.Revision.Should().Be(2);
|
|
}
|
|
|
|
// The older of two racing writes is parked in an earlier observer until the newer one has been applied.
|
|
[Test]
|
|
public async Task WatchRecordIgnoresStaleSequence()
|
|
{
|
|
var cache = new CultCache();
|
|
var key = new CultRecordKey("mesh-note:stale-sequence");
|
|
var handle = new CultRecordHandle<MeshNoteDocument>(key);
|
|
MeshNoteDocument Note(string text, int revision) =>
|
|
new() { Schema = "tests.mesh_note.v1", Text = text, Revision = revision };
|
|
await cache.UpsertAsync(Note("initial", 1), handle);
|
|
using var release = new System.Threading.ManualResetEventSlim();
|
|
using var parked = new System.Threading.ManualResetEventSlim();
|
|
using var park = cache.WatchRecord<MeshNoteDocument>(key).Subscribe(change =>
|
|
{
|
|
if (change.Document?.Revision != 2) return;
|
|
parked.Set();
|
|
release.Wait(TimeSpan.FromSeconds(30));
|
|
});
|
|
using var observed = await CultMesh.Document<MeshNoteDocument>(cache, key).ObserveAsync();
|
|
|
|
var older = Task.Run(() => cache.UpsertAsync(Note("older", 2), handle));
|
|
parked.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
|
|
var newer = Task.Run(() => cache.UpsertAsync(Note("newer", 3), handle));
|
|
var newerFinished = newer.Wait(TimeSpan.FromSeconds(10));
|
|
release.Set();
|
|
|
|
newerFinished.Should().BeTrue("a writer waited on another thread's delivery");
|
|
older.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
|
|
observed.Current.Text.Should().Be("newer");
|
|
cache.Dispose();
|
|
}
|
|
|
|
// A batch parks in an observer of another key before publishing its (older) target-key change; the mirror is
|
|
// created after a newer write landed, then the straggler is delivered.
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task ObserveAfterNewerSnapshotIgnoresStraggler(bool reactive)
|
|
{
|
|
var cache = new CultCache();
|
|
var key = new CultRecordKey("mesh-note:straggler");
|
|
var other = new CultRecordKey("mesh-note:straggler-other");
|
|
await cache.UpsertAsync(Note("initial", 1), new CultRecordHandle<MeshNoteDocument>(key));
|
|
using var release = new ManualResetEventSlim();
|
|
using var parked = new ManualResetEventSlim();
|
|
using var park = cache.WatchRecord<MeshNoteDocument>(other).Subscribe(_ =>
|
|
{
|
|
parked.Set();
|
|
release.Wait(TimeSpan.FromSeconds(30));
|
|
});
|
|
|
|
var batch = Task.Run(() => cache.Commit(stage =>
|
|
{
|
|
stage.Upsert(Note("other", 1), new CultRecordHandle<MeshNoteDocument>(other));
|
|
stage.Upsert(Note("older", 2), new CultRecordHandle<MeshNoteDocument>(key));
|
|
}));
|
|
parked.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
|
|
await cache.UpsertAsync(Note("newer", 3), new CultRecordHandle<MeshNoteDocument>(key));
|
|
var (mirror, text, _) = await Mirror(cache, key, reactive);
|
|
release.Set();
|
|
|
|
batch.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
|
|
text().Should().Be("newer");
|
|
mirror.Dispose();
|
|
cache.Dispose();
|
|
}
|
|
|
|
// The mirror's own delivery of the newer write is parked too, so only the refresh saw it before the straggler.
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task RefreshIgnoresStragglerOlderThanSnapshot(bool reactive)
|
|
{
|
|
var cache = new CultCache();
|
|
var key = new CultRecordKey("mesh-note:refresh-straggler");
|
|
var other = new CultRecordKey("mesh-note:refresh-straggler-other");
|
|
await cache.UpsertAsync(Note("initial", 1), new CultRecordHandle<MeshNoteDocument>(key));
|
|
using var releaseOlder = new ManualResetEventSlim();
|
|
using var olderParked = new ManualResetEventSlim();
|
|
using var releaseNewer = new ManualResetEventSlim();
|
|
using var newerParked = new ManualResetEventSlim();
|
|
using var parkOlder = cache.WatchRecord<MeshNoteDocument>(other).Subscribe(_ =>
|
|
{
|
|
olderParked.Set();
|
|
releaseOlder.Wait(TimeSpan.FromSeconds(30));
|
|
});
|
|
using var parkNewer = cache.WatchRecord<MeshNoteDocument>(key).Subscribe(change =>
|
|
{
|
|
if (change.Document?.Text != "newer") return;
|
|
newerParked.Set();
|
|
releaseNewer.Wait(TimeSpan.FromSeconds(30));
|
|
});
|
|
var (mirror, text, refresh) = await Mirror(cache, key, reactive);
|
|
|
|
var batch = Task.Run(() => cache.Commit(stage =>
|
|
{
|
|
stage.Upsert(Note("other", 1), new CultRecordHandle<MeshNoteDocument>(other));
|
|
stage.Upsert(Note("older", 2), new CultRecordHandle<MeshNoteDocument>(key));
|
|
}));
|
|
olderParked.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
|
|
var newer = Task.Run(() => cache.UpsertAsync(Note("newer", 3), new CultRecordHandle<MeshNoteDocument>(key)));
|
|
newerParked.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
|
|
await refresh();
|
|
releaseOlder.Set();
|
|
batch.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
|
|
text().Should().Be("newer");
|
|
releaseNewer.Set();
|
|
|
|
newer.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
|
|
text().Should().Be("newer");
|
|
mirror.Dispose();
|
|
cache.Dispose();
|
|
}
|
|
|
|
private static MeshNoteDocument Note(string text, int revision) =>
|
|
new() { Schema = "tests.mesh_note.v1", Text = text, Revision = revision };
|
|
|
|
private static async Task<(IDisposable Mirror, Func<string> Text, Func<Task> Refresh)> Mirror(
|
|
CultCache cache, CultRecordKey key, bool reactive)
|
|
{
|
|
var handle = CultMesh.Document<MeshNoteDocument>(cache, key);
|
|
if (reactive)
|
|
{
|
|
var mirror = await handle.AuthoritativeWriter().ReactiveAsync();
|
|
return (mirror, () => mirror.Snapshot.Text, () => mirror.RefreshAsync());
|
|
}
|
|
|
|
var observed = await handle.ObserveAsync();
|
|
return (observed, () => observed.Current.Text, () => observed.RefreshAsync());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_DisposeSuppressesScheduledFlush()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.dispose",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
predictions.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMilliseconds(25)
|
|
});
|
|
reactive.Update(document => document.Text = "disposed-before-flush");
|
|
reactive.Dispose();
|
|
|
|
await Task.Delay(100);
|
|
|
|
predictions.Should().BeEmpty();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_CanAdoptCanonicalSnapshotWhileDirty()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.adopt-canonical",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1),
|
|
ReplaceDirtyCurrentOnCanonicalSnapshot = true
|
|
});
|
|
reactive.Update(document => document.Text = "local-prediction");
|
|
|
|
subject.OnNext(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "canonical-wins",
|
|
Revision = 9
|
|
});
|
|
|
|
reactive.Snapshot.Text.Should().Be("canonical-wins");
|
|
reactive.Snapshot.Revision.Should().Be(9);
|
|
reactive.Reconciliation.Should().BeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_RefreshClearsDirtyLocalState()
|
|
{
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var replacements = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.refresh",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => new Subject<MeshNoteDocument>(),
|
|
value =>
|
|
{
|
|
replacements.Add(value);
|
|
current = value;
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
reactive.Update(document => document.Text = "local-dirty");
|
|
current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "fresh-canonical",
|
|
Revision = 5
|
|
};
|
|
|
|
await reactive.RefreshAsync();
|
|
await reactive.FlushAsync();
|
|
|
|
reactive.Snapshot.Text.Should().Be("fresh-canonical");
|
|
reactive.IsDirty.Should().BeFalse();
|
|
replacements.Should().BeEmpty();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_SyncsAcrossRuntimeHandles()
|
|
{
|
|
var cache = new CultCache();
|
|
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
|
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
|
{
|
|
RuntimeId = "pilot-a",
|
|
Shards = new[]
|
|
{
|
|
new CultNetShardDescriptor(
|
|
"pilot-inputs",
|
|
"server",
|
|
epoch: 1,
|
|
isPrimary: false,
|
|
schemaIds: new[] { schemaId },
|
|
keyPrefix: "input:")
|
|
},
|
|
ClientAuthorityScopes = new[]
|
|
{
|
|
new CultNetClientAuthorityScope(
|
|
"pilot-a",
|
|
schemaIds: new[] { schemaId },
|
|
keyPrefix: "input:pilot-a")
|
|
}
|
|
});
|
|
var key = new CultRecordKey("input:pilot-a:reactive");
|
|
await cache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
var pilotHandle = CultMesh.Document<MeshNoteDocument>(
|
|
database,
|
|
key,
|
|
CultMesh.Verse("starbridge", "pilot-a"));
|
|
var commanderHandle = CultMesh.Document<MeshNoteDocument>(
|
|
database,
|
|
key,
|
|
CultMesh.Verse("starbridge", "commander-rts"));
|
|
var observedByCommander = new List<MeshNoteDocument>();
|
|
using var commanderSubscription = commanderHandle.Watch(observedByCommander.Add);
|
|
using var pilotReactive = await CultMesh
|
|
.Documents(pilotHandle)
|
|
.PredictionWriter<MeshNoteDocument>()
|
|
.ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
|
|
pilotReactive.Update(document =>
|
|
{
|
|
document.Text = "pilot-predicted";
|
|
document.Revision = 2;
|
|
});
|
|
await pilotReactive.FlushAsync();
|
|
|
|
await WaitForAsync(() => observedByCommander.Any(document => document.Text == "pilot-predicted"));
|
|
|
|
commanderHandle.Context.RuntimeId.Should().Be("commander-rts");
|
|
observedByCommander.Last().Revision.Should().Be(2);
|
|
(await commanderHandle.LatestAsync()).Text.Should().Be("pilot-predicted");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_TwoRuntimeManagedDocumentsSyncThroughDatabase()
|
|
{
|
|
var cache = new CultCache();
|
|
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
|
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
|
{
|
|
RuntimeId = "pilot-a",
|
|
Shards = new[]
|
|
{
|
|
new CultNetShardDescriptor(
|
|
"pilot-inputs",
|
|
"server",
|
|
epoch: 1,
|
|
isPrimary: false,
|
|
schemaIds: new[] { schemaId },
|
|
keyPrefix: "input:")
|
|
},
|
|
ClientAuthorityScopes = new[]
|
|
{
|
|
new CultNetClientAuthorityScope(
|
|
"pilot-a",
|
|
schemaIds: new[] { schemaId },
|
|
keyPrefix: "input:pilot-a")
|
|
}
|
|
});
|
|
var key = new CultRecordKey("input:pilot-a:co-op");
|
|
await cache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
var pilotHandle = CultMesh.Document<MeshNoteDocument>(
|
|
database,
|
|
key,
|
|
CultMesh.Verse("starbridge", "pilot-a"));
|
|
var commanderHandle = CultMesh.Document<MeshNoteDocument>(
|
|
database,
|
|
key,
|
|
CultMesh.Verse("starbridge", "commander-rts"));
|
|
MeshNoteDocument commanderSnapshot = null!;
|
|
using var commanderSubscription = commanderHandle.Watch(value => commanderSnapshot = value);
|
|
using var pilotReactive = await pilotHandle
|
|
.PredictionWriter()
|
|
.ReactiveAsync(new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
|
|
pilotReactive.Update(document =>
|
|
{
|
|
document.Text = "pilot-first-touch";
|
|
document.Revision++;
|
|
});
|
|
pilotReactive.Update(document =>
|
|
{
|
|
document.Text = "pilot-coalesced-order";
|
|
document.Revision++;
|
|
});
|
|
await pilotReactive.FlushAsync();
|
|
|
|
await WaitForAsync(() => commanderSnapshot?.Text == "pilot-coalesced-order");
|
|
|
|
pilotReactive.IsDirty.Should().BeFalse();
|
|
commanderHandle.Context.RuntimeId.Should().Be("commander-rts");
|
|
commanderSnapshot.Revision.Should().Be(3);
|
|
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("pilot-coalesced-order");
|
|
}
|
|
|
|
[Test]
|
|
public async Task NodeDocument_OpensExplicitPredictionWriter()
|
|
{
|
|
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
|
using var temp = new TemporaryDirectory();
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(temp.Path, "node-reactive-alias.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
RuntimeId = "pilot-a",
|
|
Shards = new[]
|
|
{
|
|
new CultNetShardDescriptor(
|
|
"pilot-inputs",
|
|
"server",
|
|
epoch: 1,
|
|
isPrimary: false,
|
|
schemaIds: new[] { schemaId },
|
|
keyPrefix: "input:")
|
|
},
|
|
ClientAuthorityScopes = new[]
|
|
{
|
|
new CultNetClientAuthorityScope(
|
|
"pilot-a",
|
|
schemaIds: new[] { schemaId },
|
|
keyPrefix: "input:pilot-a")
|
|
}
|
|
}
|
|
});
|
|
var key = new CultRecordKey("input:pilot-a:node-reactive");
|
|
await node.Cache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "node-initial",
|
|
Revision = 1
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
using var pilotReactive = await node
|
|
.Document<MeshNoteDocument>(key, CultMesh.Verse("starbridge", "pilot-a"))
|
|
.PredictionWriter()
|
|
.ReactiveAsync(new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
var commanderHandle = node.Document<MeshNoteDocument>(
|
|
key.Value,
|
|
CultMesh.Verse("starbridge", "commander-rts"));
|
|
var observedByCommander = new List<MeshNoteDocument>();
|
|
using var commanderSubscription = commanderHandle.Watch(observedByCommander.Add);
|
|
|
|
pilotReactive.Update(document =>
|
|
{
|
|
document.Text = "node-one-call-prediction";
|
|
document.Revision = 2;
|
|
});
|
|
await pilotReactive.FlushAsync();
|
|
|
|
await WaitForAsync(() => observedByCommander.Any(document => document.Text == "node-one-call-prediction"));
|
|
pilotReactive.Document.DocumentId.Should().Be(key.Value);
|
|
pilotReactive.Document.Context.RuntimeId.Should().Be("pilot-a");
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("node-one-call-prediction");
|
|
(await commanderHandle.LatestAsync()).Revision.Should().Be(2);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SyncedSnapshotEndpointObservesCanonicalCorrectionAcrossRuntimeBoundary()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
var key = new CultRecordKey("mesh-note:remote-correction");
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var endpoint = "cultnet://remote-correction.test:3080";
|
|
var surface = CultMesh.SnapshotEndpoint(
|
|
endpoint,
|
|
new CultMeshSnapshotEndpointOptions
|
|
{
|
|
Context = CultMesh.Verse("starbridge", "unity-pilot").Context,
|
|
DocumentRegistry = registry,
|
|
PollInterval = TimeSpan.FromMilliseconds(10),
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
{
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request))
|
|
}
|
|
});
|
|
|
|
using var temp = new TemporaryDirectory();
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(temp.Path, "pilot-runtime.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = sourceCache.Registry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
});
|
|
var handle = surface.SyncTo(node).Document<MeshNoteDocument>(key.Value);
|
|
MeshNoteDocument observed = null!;
|
|
using var subscription = handle.Watch(value => observed = value);
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "remote-authority-correction",
|
|
Revision = 7
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
await WaitForAsync(() => observed?.Text == "remote-authority-correction");
|
|
|
|
observed.Revision.Should().Be(7);
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("remote-authority-correction");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_FreshRuntimeReconstructsFromSyncedSnapshotEndpoint()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
var key = new CultRecordKey("mesh-note:runtime-reconnect");
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "before-crash",
|
|
Revision = 1
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var endpoint = "cultnet://runtime-reconnect.test:3081";
|
|
var surface = CultMesh.SnapshotEndpoint(
|
|
endpoint,
|
|
new CultMeshSnapshotEndpointOptions
|
|
{
|
|
Context = CultMesh.Verse("starbridge", "unity-reconnected").Context,
|
|
DocumentRegistry = registry,
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
{
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request))
|
|
}
|
|
});
|
|
|
|
using var temp = new TemporaryDirectory();
|
|
using (var crashedRuntime = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(temp.Path, "crashed-runtime.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = sourceCache.Registry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
}))
|
|
{
|
|
var stale = await surface.SyncTo(crashedRuntime).Document<MeshNoteDocument>(key.Value).LatestAsync();
|
|
stale.Text.Should().Be("before-crash");
|
|
}
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "after-reboot-authority",
|
|
Revision = 4
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
using var freshRuntime = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(temp.Path, "fresh-runtime.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = sourceCache.Registry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
});
|
|
var reconstructed = await surface
|
|
.SyncTo(freshRuntime)
|
|
.Documents(CultMesh.SnapshotDocument<MeshNoteDocument>(key.Value))
|
|
.LatestAsync<MeshNoteDocument>();
|
|
|
|
reconstructed.Text.Should().Be("after-reboot-authority");
|
|
reconstructed.Revision.Should().Be(4);
|
|
freshRuntime.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("after-reboot-authority");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_ReadOnlySnapshotEndpointRefreshRecoversAfterRejectedLocalEdit()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
var key = new CultRecordKey("mesh-note:readonly-refresh");
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var endpoint = "cultnet://readonly-refresh.test:3082";
|
|
var surface = CultMesh.SnapshotEndpoint(
|
|
endpoint,
|
|
new CultMeshSnapshotEndpointOptions
|
|
{
|
|
Context = CultMesh.Verse("starbridge", "observer").Context,
|
|
DocumentRegistry = registry,
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
{
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request))
|
|
}
|
|
});
|
|
|
|
using var temp = new TemporaryDirectory();
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(temp.Path, "observer-runtime.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = sourceCache.Registry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
});
|
|
var readOnlyHandle = surface
|
|
.SyncTo(node)
|
|
.Document<MeshNoteDocument>(key.Value);
|
|
readOnlyHandle.CanReplace.Should().BeFalse();
|
|
readOnlyHandle.CanSubmitPrediction.Should().BeFalse();
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "fresh-authority",
|
|
Revision = 5
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var refreshed = await readOnlyHandle.LatestAsync();
|
|
|
|
refreshed.Text.Should().Be("fresh-authority");
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(5);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_SnapshotMutationCannotPublish()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.mark-dirty",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
predictions.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
|
|
var escapedSnapshot = reactive.Snapshot;
|
|
escapedSnapshot.Text = "must-not-publish";
|
|
escapedSnapshot.Revision = 12;
|
|
await reactive.FlushAsync();
|
|
|
|
predictions.Should().BeEmpty();
|
|
reactive.Snapshot.Text.Should().Be("initial");
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_IdleMirrorDoesNotPublishUntilExplicitUpdate()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.direct-member-auto",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
predictions.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMilliseconds(10)
|
|
});
|
|
|
|
await Task.Delay(75);
|
|
predictions.Should().BeEmpty();
|
|
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "explicit-member-edit";
|
|
document.Revision = 3;
|
|
});
|
|
|
|
await WaitForAsync(() => predictions.Count == 1);
|
|
|
|
predictions[0].Text.Should().Be("explicit-member-edit");
|
|
predictions[0].Revision.Should().Be(3);
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[TestCase(1)]
|
|
[TestCase(100)]
|
|
[TestCase(1000)]
|
|
public async Task ReactiveDocument_SchedulingScalesWithChangedDocumentsOnly(int documentCount)
|
|
{
|
|
var clock = new BlockingCountClock();
|
|
var reactiveDocuments = new List<CultMeshReactiveDocument<MeshNoteDocument>>(documentCount);
|
|
|
|
try
|
|
{
|
|
for (var index = 0; index < documentCount; index++)
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = $"initial-{index}",
|
|
Revision = 1
|
|
};
|
|
var handle = CultMesh.Document(
|
|
$"mesh.note.reactive.scale.{index}",
|
|
CultMesh.Verse("scale", "test"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
reactiveDocuments.Add(await handle.AuthoritativeWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
Clock = clock,
|
|
FlushDelay = TimeSpan.FromSeconds(1)
|
|
}));
|
|
}
|
|
|
|
clock.DelayCount.Should().Be(0, "idle reactive documents must not schedule background work");
|
|
|
|
var changedDocumentCount = Math.Max(1, documentCount / 100);
|
|
for (var index = 0; index < changedDocumentCount; index++)
|
|
{
|
|
reactiveDocuments[index].Update(document =>
|
|
{
|
|
document.Text = $"changed-{index}";
|
|
document.Revision++;
|
|
});
|
|
}
|
|
|
|
clock.DelayCount.Should().Be(
|
|
changedDocumentCount,
|
|
"flush scheduling must be proportional to explicitly changed documents");
|
|
reactiveDocuments.Count(document => document.IsDirty).Should().Be(changedDocumentCount);
|
|
}
|
|
finally
|
|
{
|
|
foreach (var reactiveDocument in reactiveDocuments)
|
|
reactiveDocument.Dispose();
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_ReplaceLocalFlushesWholeDocument()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.direct-member-flush",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
predictions.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
|
|
reactive.ReplaceLocal(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "whole-document-replacement",
|
|
Revision = 4
|
|
});
|
|
await reactive.FlushAsync();
|
|
|
|
predictions.Should().ContainSingle();
|
|
predictions[0].Text.Should().Be("whole-document-replacement");
|
|
predictions[0].Revision.Should().Be(4);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_CleanFlushDoesNotWrite()
|
|
{
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var writes = 0;
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.clean-flush",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => new Subject<MeshNoteDocument>(),
|
|
value =>
|
|
{
|
|
writes++;
|
|
current = value;
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync();
|
|
|
|
await reactive.FlushAsync();
|
|
|
|
writes.Should().Be(0);
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_FlushesAgainWhenEditedDuringInFlightFlush()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var allowFirstWrite = new TaskCompletionSource<bool>();
|
|
var firstWriteStarted = new TaskCompletionSource<bool>();
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.in-flight",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
async value =>
|
|
{
|
|
predictions.Add(value);
|
|
if (predictions.Count == 1)
|
|
{
|
|
firstWriteStarted.SetResult(true);
|
|
await allowFirstWrite.Task.ConfigureAwait(false);
|
|
}
|
|
|
|
current = value;
|
|
subject.OnNext(value);
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "first";
|
|
document.Revision = 2;
|
|
});
|
|
var flush = reactive.FlushAsync();
|
|
await firstWriteStarted.Task;
|
|
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "second";
|
|
document.Revision = 3;
|
|
});
|
|
allowFirstWrite.SetResult(true);
|
|
await flush;
|
|
|
|
predictions.Should().HaveCount(2);
|
|
predictions[0].Text.Should().Be("first");
|
|
predictions[1].Text.Should().Be("second");
|
|
current.Revision.Should().Be(3);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_DebouncesBurstUpdatesIntoOnePrediction()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.burst",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
predictions.Add(value);
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMilliseconds(35)
|
|
});
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = $"burst-{i}";
|
|
document.Revision++;
|
|
});
|
|
}
|
|
|
|
await WaitForAsync(() => predictions.Count == 1);
|
|
|
|
predictions[0].Text.Should().Be("burst-4");
|
|
predictions[0].Revision.Should().Be(6);
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_ExplicitEditsDuringInFlightFlushQueueLatestPrediction()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var allowFirstWrite = new TaskCompletionSource<bool>();
|
|
var firstWriteStarted = new TaskCompletionSource<bool>();
|
|
var predictions = new List<MeshNoteDocument>();
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.direct-in-flight",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
async value =>
|
|
{
|
|
predictions.Add(value);
|
|
if (predictions.Count == 1)
|
|
{
|
|
firstWriteStarted.SetResult(true);
|
|
await allowFirstWrite.Task.ConfigureAwait(false);
|
|
}
|
|
|
|
current = value;
|
|
subject.OnNext(value);
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "first-explicit";
|
|
document.Revision = 2;
|
|
});
|
|
var flush = reactive.FlushAsync();
|
|
await firstWriteStarted.Task;
|
|
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "second-explicit";
|
|
document.Revision = 3;
|
|
});
|
|
allowFirstWrite.SetResult(true);
|
|
await flush;
|
|
|
|
predictions.Should().HaveCount(2);
|
|
predictions[0].Text.Should().Be("first-explicit");
|
|
predictions[1].Text.Should().Be("second-explicit");
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReactiveDocument_CanonicalEchoClearsOutstandingPredictionWithoutDelta()
|
|
{
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
var current = new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "initial",
|
|
Revision = 1
|
|
};
|
|
var handle = CultMesh.Document(
|
|
"mesh.note.reactive.canonical-echo",
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
_ => Task.FromResult(current),
|
|
_ => subject,
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
},
|
|
value =>
|
|
{
|
|
current = value;
|
|
subject.OnNext(value);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
|
new CultMeshReactiveDocumentOptions
|
|
{
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
});
|
|
reactive.Update(document =>
|
|
{
|
|
document.Text = "accepted";
|
|
document.Revision = 2;
|
|
});
|
|
|
|
await reactive.FlushAsync();
|
|
|
|
reactive.Snapshot.Text.Should().Be("accepted");
|
|
reactive.Reconciliation.Should().BeNull();
|
|
reactive.IsDirty.Should().BeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentHandle_ReadsSchemaPublicationsFromSingleFileStores()
|
|
{
|
|
using var directory = new TemporaryDirectory();
|
|
var filePath = Path.Combine(directory.Path, "publication.ccmp");
|
|
var key = new CultRecordKey("mesh-note:publication");
|
|
|
|
var writerCache = new CultCache();
|
|
writerCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
|
await writerCache.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
|
{
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
Text = "published",
|
|
Revision = 1
|
|
}, key);
|
|
writerCache.FlushAllBackingStores();
|
|
File.Exists(filePath).Should().BeTrue();
|
|
|
|
var directReadCache = new CultCache();
|
|
directReadCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
|
await directReadCache.PullAllBackingStoresAsync();
|
|
directReadCache.Get<MeshPublicationNoteDocument>(key)!.Text.Should().Be("published");
|
|
|
|
var handle = CultMesh.DocumentFromPublication<MeshPublicationNoteDocument>(
|
|
CultMeshDocumentPublicationSource.SingleFile(filePath),
|
|
key,
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
new CultMeshStoreDocumentOptions
|
|
{
|
|
DocumentId = "daemon:tests.mesh_note.latest",
|
|
SourceId = "daemon:tests.mesh_note.latest.v1",
|
|
PollInterval = TimeSpan.FromMilliseconds(10)
|
|
});
|
|
var observed = new List<MeshPublicationNoteDocument>();
|
|
using var subscription = handle.Watch(value => observed.Add(value));
|
|
|
|
(await handle.LatestAsync()).Text.Should().Be("published");
|
|
handle.CanReplace.Should().BeFalse();
|
|
handle.CanSubmitPrediction.Should().BeFalse();
|
|
handle.DocumentId.Should().Be("daemon:tests.mesh_note.latest");
|
|
handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:tests.mesh_note.latest.v1");
|
|
|
|
var republisher = new CultCache();
|
|
republisher.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
|
await republisher.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
|
{
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
Text = "republished",
|
|
Revision = 2
|
|
}, key);
|
|
republisher.FlushAllBackingStores();
|
|
|
|
await WaitForAsync(() => observed.Any(value => value.Text == "republished"));
|
|
(await handle.LatestAsync()).Revision.Should().Be(2);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SingleFileDocumentHelpers_RoundTripTypedDocuments()
|
|
{
|
|
using var directory = new TemporaryDirectory();
|
|
var filePath = Path.Combine(directory.Path, "single-document.ccmp");
|
|
var key = new CultRecordKey("mesh-note:single-document");
|
|
|
|
CultMesh.WriteSingleFileDocument(
|
|
filePath,
|
|
key,
|
|
new MeshPublicationNoteDocument
|
|
{
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
Text = "published-directly",
|
|
Revision = 3
|
|
},
|
|
storedAt: "2026-06-27T12:00:00.0000000Z");
|
|
|
|
var document = CultMesh.ReadSingleFileDocument<MeshPublicationNoteDocument>(filePath, key);
|
|
|
|
document.Text.Should().Be("published-directly");
|
|
document.Revision.Should().Be(3);
|
|
|
|
var cache = new CultCache();
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
|
await cache.PullAllBackingStoresAsync();
|
|
cache.Get<MeshPublicationNoteDocument>(key)!.Text.Should().Be("published-directly");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments()
|
|
{
|
|
var cache = new CultCache();
|
|
var key = new CultRecordKey("mesh-note:remote");
|
|
await cache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "remote-snapshot",
|
|
Revision = 9
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared)
|
|
.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(CultDocumentRegistry.Shared));
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
|
|
var handle = CultMesh.DocumentFromPublication<MeshNoteDocument>(
|
|
CultMeshDocumentPublicationSource.PeerSnapshot(
|
|
() => new MeshSnapshotSchemaClient(request =>
|
|
{
|
|
requests.Add(request);
|
|
return registry.CreateRawSnapshotResponse(cache, request.MessageId, request);
|
|
}),
|
|
"cultnet://snapshot.test:3075"),
|
|
key,
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
peerOptions: new CultMeshPeerSnapshotDocumentOptions
|
|
{
|
|
PollInterval = TimeSpan.FromMilliseconds(10),
|
|
MessageIdPrefix = "mesh-test-snapshot"
|
|
});
|
|
var catalog = CultMesh.Documents(handle);
|
|
|
|
var snapshot = await handle.LatestAsync();
|
|
|
|
snapshot.Text.Should().Be("remote-snapshot");
|
|
snapshot.Revision.Should().Be(9);
|
|
handle.DocumentType.Should().Be(typeof(MeshNoteDocument));
|
|
handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be(key.Value);
|
|
requests.Should().ContainSingle();
|
|
requests[0].SchemaIds.Should().ContainSingle().Which
|
|
.Should().Be(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId);
|
|
requests[0].RecordKeys.Should().ContainSingle().Which.Should().Be(key.Value);
|
|
(await catalog.LatestAsync<MeshNoteDocument>()).Text.Should().Be("remote-snapshot");
|
|
|
|
var aliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot<MeshNoteDocument>(
|
|
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
|
|
{
|
|
MessageId = "alias-schema",
|
|
Documents = new[]
|
|
{
|
|
new CultNetRawDocumentRecord
|
|
{
|
|
SchemaId = "remote.generated.mesh_note_alias.v99",
|
|
RecordKey = key.Value,
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
PayloadEncoding = "messagepack",
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "record-key-fallback",
|
|
Revision = 10
|
|
},
|
|
typeof(MeshNoteDocument))
|
|
}
|
|
}
|
|
}),
|
|
key.Value,
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
(await aliasSchemaHandle.LatestAsync()).Text.Should().Be("record-key-fallback");
|
|
|
|
var mixedAliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot<MeshNoteDocument>(
|
|
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
|
|
{
|
|
MessageId = "alias-schema-with-neighbor",
|
|
Documents = new[]
|
|
{
|
|
new CultNetRawDocumentRecord
|
|
{
|
|
SchemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId,
|
|
RecordKey = "mesh-note:other",
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
PayloadEncoding = "messagepack",
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "wrong-schema-neighbor",
|
|
Revision = 11
|
|
},
|
|
typeof(MeshNoteDocument))
|
|
},
|
|
new CultNetRawDocumentRecord
|
|
{
|
|
SchemaId = "remote.generated.mesh_note_alias.v100",
|
|
RecordKey = key.Value,
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
PayloadEncoding = "messagepack",
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "compatible-record-key",
|
|
Revision = 12
|
|
},
|
|
typeof(MeshNoteDocument))
|
|
}
|
|
}
|
|
}),
|
|
key.Value,
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
(await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key");
|
|
|
|
// The recordKey match is exact (ordinal), not case-insensitive. A case-variant key must not
|
|
// win even when it appears first in the response.
|
|
var caseVariantKey = key.Value.ToUpperInvariant();
|
|
var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot<MeshNoteDocument>(
|
|
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
|
|
{
|
|
MessageId = "case-variant-key",
|
|
Documents = new[]
|
|
{
|
|
new CultNetRawDocumentRecord
|
|
{
|
|
SchemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId,
|
|
RecordKey = caseVariantKey,
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
PayloadEncoding = "messagepack",
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "wrong-case-key",
|
|
Revision = 13
|
|
},
|
|
typeof(MeshNoteDocument))
|
|
},
|
|
new CultNetRawDocumentRecord
|
|
{
|
|
SchemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId,
|
|
RecordKey = key.Value,
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
PayloadEncoding = "messagepack",
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "right-case-key",
|
|
Revision = 14
|
|
},
|
|
typeof(MeshNoteDocument))
|
|
}
|
|
}
|
|
}),
|
|
key.Value,
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
(await caseVariantKeyHandle.LatestAsync()).Text.Should().Be("right-case-key");
|
|
}
|
|
|
|
// R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22) corrects the earlier commit-2
|
|
// ruling this test used to pin: an explicit empty recordKeys array is a real filter, not "no
|
|
// filter" that falls back to the type's own schema default. Discrepancy against R-R's prose,
|
|
// reported rather than silently resolved: at CultMesh's typed convenience layer this selection
|
|
// never reaches the wire at all. ResolveDefaultSelection now produces Keys=[] instead of Keys=null,
|
|
// and EnsureV0Compatible - CultMesh's own client-side door, landed under the earlier ruling R-F -
|
|
// already refuses an empty selection.keys before any request is sent ("Keys=[]/[\"\"] is refused
|
|
// rather than silently lowered to 'every key'", CultMeshSnapshots.cs:836-841). R-R's "recordKeys:
|
|
// [] answers empty" therefore only holds for the raw v0 wire protocol server, which any v0 peer
|
|
// can call directly without CultMesh's typed wrapper - see
|
|
// CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty in NetworkingTests.cs.
|
|
[Test]
|
|
public void FetchDocumentsAsync_WithExplicitEmptyRecordKeys_IsRefusedClientSide()
|
|
{
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared)
|
|
.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(CultDocumentRegistry.Shared));
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
|
|
using var session = CultMesh.SnapshotSession(
|
|
"cultnet://empty-keys-default.test:3075",
|
|
new CultMeshSnapshotRequestOptions
|
|
{
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
{
|
|
requests.Add(request);
|
|
return registry.CreateRawSnapshotResponse(new CultCache(), request.MessageId, request);
|
|
})
|
|
},
|
|
registry);
|
|
|
|
Assert.ThrowsAsync<CultNetSelectionInvalidException>(
|
|
() => session.FetchDocumentsAsync<MeshNoteDocument>(recordKeys: Array.Empty<string>()));
|
|
requests.Should().BeEmpty();
|
|
}
|
|
|
|
[Test]
|
|
public async Task PublicationHelpers_BindConfiguredDocumentsIntoCatalog()
|
|
{
|
|
using var directory = new TemporaryDirectory();
|
|
var firstPath = Path.Combine(directory.Path, "first.ccmp");
|
|
var secondPath = Path.Combine(directory.Path, "second.ccmp");
|
|
var firstKey = new CultRecordKey("mesh-note:publication-catalog-first");
|
|
var secondKey = new CultRecordKey("mesh-note:publication-catalog-second");
|
|
var firstWriter = new CultCache();
|
|
firstWriter.AddBackingStore(new SingleFileMessagePackBackingStore(firstPath));
|
|
await firstWriter.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
|
{
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
Text = "first source",
|
|
Revision = 21
|
|
}, firstKey);
|
|
firstWriter.FlushAllBackingStores();
|
|
var secondWriter = new CultCache();
|
|
secondWriter.AddBackingStore(new SingleFileMessagePackBackingStore(secondPath));
|
|
await secondWriter.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "second source",
|
|
Revision = 22
|
|
}, new CultRecordHandle<MeshNoteDocument>(secondKey));
|
|
secondWriter.FlushAllBackingStores();
|
|
|
|
var catalog = CultMesh.DocumentsFromPublication(
|
|
CultMeshDocumentPublicationSource.SingleFile(firstPath),
|
|
new ICultMeshPublicationDocumentBinding[]
|
|
{
|
|
CultMesh.PublicationDocument<MeshPublicationNoteDocument>(
|
|
firstKey,
|
|
documentId: "daemon:first"),
|
|
CultMesh.PublicationDocument<MeshNoteDocument>(
|
|
secondKey,
|
|
documentId: "daemon:second",
|
|
source: CultMeshDocumentPublicationSource.SingleFile(secondPath))
|
|
},
|
|
CultMesh.Verse("starbridge", "unity-catalog"),
|
|
new CultMeshStoreDocumentOptions
|
|
{
|
|
RouteHint = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "publication catalog"),
|
|
PollInterval = TimeSpan.FromMilliseconds(10)
|
|
});
|
|
|
|
catalog.Documents.Select(document => document.DocumentId).Should().Equal("daemon:first", "daemon:second");
|
|
(await catalog.LatestAsync<MeshPublicationNoteDocument>()).Text.Should().Be("first source");
|
|
(await catalog.LatestAsync<MeshNoteDocument>()).Text.Should().Be("second source");
|
|
catalog.Document<MeshNoteDocument>().DocumentId.Should().Be("daemon:second");
|
|
catalog.Document<MeshPublicationNoteDocument>().RouteHint.Description.Should().Be("publication catalog");
|
|
catalog.Document<MeshNoteDocument>().Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:second");
|
|
}
|
|
|
|
[Test]
|
|
public async Task PublicationHelpers_SyncConfiguredDocumentsIntoLocalCatalog()
|
|
{
|
|
using var directory = new TemporaryDirectory();
|
|
var firstPath = Path.Combine(directory.Path, "sync-first.ccmp");
|
|
var secondPath = Path.Combine(directory.Path, "sync-second.ccmp");
|
|
var firstKey = new CultRecordKey("mesh-note:publication-sync-catalog-first");
|
|
var secondKey = new CultRecordKey("mesh-note:publication-sync-catalog-second");
|
|
var firstWriter = new CultCache();
|
|
firstWriter.AddBackingStore(new SingleFileMessagePackBackingStore(firstPath));
|
|
await firstWriter.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
|
{
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
Text = "first synced source",
|
|
Revision = 31
|
|
}, firstKey);
|
|
firstWriter.FlushAllBackingStores();
|
|
var secondWriter = new CultCache();
|
|
secondWriter.AddBackingStore(new SingleFileMessagePackBackingStore(secondPath));
|
|
await secondWriter.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "second synced source",
|
|
Revision = 32
|
|
}, new CultRecordHandle<MeshNoteDocument>(secondKey));
|
|
secondWriter.FlushAllBackingStores();
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(directory.Path, "target.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false
|
|
});
|
|
|
|
var catalog = await CultMesh.SyncDocumentsFromPublicationAsync(
|
|
node,
|
|
CultMeshDocumentPublicationSource.SingleFile(firstPath),
|
|
new ICultMeshPublicationDocumentBinding[]
|
|
{
|
|
CultMesh.PublicationDocument<MeshPublicationNoteDocument>(
|
|
firstKey,
|
|
documentId: "local:first"),
|
|
CultMesh.PublicationDocument<MeshNoteDocument>(
|
|
secondKey,
|
|
documentId: "local:second",
|
|
source: CultMeshDocumentPublicationSource.SingleFile(secondPath))
|
|
},
|
|
CultMesh.Verse("starbridge", "unity-sync-catalog"),
|
|
new CultMeshStoreDocumentOptions
|
|
{
|
|
PollInterval = TimeSpan.FromMilliseconds(10)
|
|
});
|
|
|
|
catalog.Documents.Select(document => document.DocumentId).Should().Equal("local:first", "local:second");
|
|
(await catalog.LatestAsync<MeshPublicationNoteDocument>()).Text.Should().Be("first synced source");
|
|
(await catalog.LatestAsync<MeshNoteDocument>()).Text.Should().Be("second synced source");
|
|
node.Cache.Get<MeshPublicationNoteDocument>(firstKey)!.Revision.Should().Be(31);
|
|
(await node.Document<MeshNoteDocument>(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog"))
|
|
.LatestAsync()).Text.Should().Be("second synced source");
|
|
(await node.Document<MeshNoteDocument>(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog"))
|
|
.LatestAsync()).Revision.Should().Be(32);
|
|
}
|
|
|
|
[Test]
|
|
public async Task PublicationHelpers_SyncConfiguredPeerSourceIntoLocalNode()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
var key = new CultRecordKey("mesh-note:publication-sync-alias");
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "publication-synced",
|
|
Revision = 51
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
var verse = CultMesh.Verse("starbridge", "unity-support");
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-publication-sync-alias-{Guid.NewGuid():N}.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = sourceCache.Registry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
});
|
|
|
|
var synced = await CultMesh.SyncDocumentFromPublicationAsync<MeshNoteDocument>(
|
|
node,
|
|
CultMeshDocumentPublicationSource.PeerSnapshot(
|
|
() => new MeshSnapshotSchemaClient(request =>
|
|
{
|
|
requests.Add(request);
|
|
return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request);
|
|
}),
|
|
"cultnet://publication-sync.test:3075"),
|
|
key,
|
|
verse,
|
|
peerOptions: new CultMeshPeerSnapshotDocumentOptions
|
|
{
|
|
MessageIdPrefix = "mesh-test-publication-sync",
|
|
PollInterval = TimeSpan.FromMilliseconds(10)
|
|
});
|
|
var local = await node.Document<MeshNoteDocument>(key, verse).LatestAsync();
|
|
|
|
synced.Text.Should().Be("publication-synced");
|
|
synced.Revision.Should().Be(51);
|
|
local.Text.Should().Be("publication-synced");
|
|
local.Revision.Should().Be(51);
|
|
requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotHelpers_FetchApplyAndDecodeScopedSnapshots()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var targetCache = new CultCache();
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshPublicationNoteDocument>(sourceCache.Registry));
|
|
var key = new CultRecordKey("mesh-note:snapshot-helper");
|
|
await sourceCache.UpsertAsync(new MeshPublicationNoteDocument
|
|
{
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
Text = "snapshot-helper",
|
|
Revision = 11
|
|
}, new CultRecordHandle<MeshPublicationNoteDocument>(key));
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
var endpoint = "cultnet://snapshot-helper.test:3075";
|
|
var options = new CultMeshSnapshotRequestOptions
|
|
{
|
|
Selection = new CultNetSelection { Keys = new[] { key.Value } },
|
|
ShardId = "primary",
|
|
ShardEpoch = 7,
|
|
MessageIdPrefix = "mesh-test-scoped-snapshot",
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
{
|
|
requests.Add(request);
|
|
return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request);
|
|
})
|
|
};
|
|
|
|
var snapshot = await CultMesh.FetchSnapshotAsync(endpoint, options);
|
|
var decoded = await CultMesh.FetchSnapshotDocumentsAsync<MeshPublicationNoteDocument>(
|
|
endpoint,
|
|
options,
|
|
registry);
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-snapshot-helper-{Guid.NewGuid():N}.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = targetCache.Registry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
});
|
|
var applied = await CultMesh.ApplySnapshotAsync(node, endpoint, options);
|
|
|
|
snapshot.Documents.Should().ContainSingle().Which.RecordKey.Should().Be(key.Value);
|
|
decoded.Should().ContainSingle().Which.Text.Should().Be("snapshot-helper");
|
|
applied.Should().ContainSingle();
|
|
node.Cache.Get<MeshPublicationNoteDocument>(key)!.Revision.Should().Be(11);
|
|
requests.Should().HaveCount(3);
|
|
requests.Should().OnlyContain(request =>
|
|
request.RecordKeys!.SequenceEqual(new[] { key.Value }) &&
|
|
request.ShardId == "primary" &&
|
|
request.ShardEpoch == 7 &&
|
|
request.MessageId.StartsWith("mesh-test-scoped-snapshot:", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotHelpers_SurfaceBackgroundClientFailureBeforeTimeout()
|
|
{
|
|
var options = new CultMeshSnapshotRequestOptions
|
|
{
|
|
ResponseTimeout = TimeSpan.FromSeconds(30),
|
|
Selection = new CultNetSelection { Keys = new[] { "eve:surface:test" } },
|
|
CreateClient = () => new FailedBackgroundSchemaClient(
|
|
new FormatException("malformed fragment"))
|
|
};
|
|
|
|
Func<Task> act = async () => await CultMesh.FetchSnapshotAsync(
|
|
"rudp://127.0.0.1:3075",
|
|
options);
|
|
|
|
var failure = await act.Should().ThrowAsync<InvalidOperationException>();
|
|
failure.Which.InnerException.Should().BeOfType<FormatException>();
|
|
failure.Which.Message.Should().Contain("127.0.0.1:3075");
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotHelpers_SurfaceBackgroundClientFailureWhileConnecting()
|
|
{
|
|
var options = new CultMeshSnapshotRequestOptions
|
|
{
|
|
ConnectTimeout = TimeSpan.FromSeconds(30),
|
|
CreateClient = () => new FailedBackgroundSchemaClient(
|
|
new FormatException("malformed handshake"),
|
|
connected: false)
|
|
};
|
|
|
|
Func<Task> act = async () => await CultMesh.FetchSnapshotAsync(
|
|
"rudp://127.0.0.1:3075",
|
|
options);
|
|
|
|
var failure = await act.Should().ThrowAsync<InvalidOperationException>();
|
|
failure.Which.InnerException.Should().BeOfType<FormatException>();
|
|
failure.Which.Message.Should().Contain("connecting");
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotHelpers_DecodeFromForeignSchemaId()
|
|
{
|
|
var key = new CultRecordKey("mesh-note:snapshot-alias-foreign-schema");
|
|
var snapshot = new CultNetSnapshotResponseRawMessage
|
|
{
|
|
MessageId = "foreign-schema",
|
|
Documents = new[]
|
|
{
|
|
new CultNetRawDocumentRecord
|
|
{
|
|
SchemaId = "runtime.generated.mesh-note.ui.42",
|
|
RecordKey = key.Value,
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
PayloadEncoding = "messagepack",
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "foreign-schema-alias",
|
|
Revision = 44
|
|
},
|
|
typeof(MeshNoteDocument))
|
|
}
|
|
}
|
|
};
|
|
|
|
var documents = await CultMesh.FetchSnapshotDocumentsAsync<MeshNoteDocument>(
|
|
"cultnet://foreign-schema.test:3075",
|
|
new CultMeshSnapshotRequestOptions
|
|
{
|
|
Selection = new CultNetSelection { Keys = new[] { key.Value } },
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
{
|
|
snapshot.MessageId = request.MessageId;
|
|
return snapshot;
|
|
})
|
|
},
|
|
new CultNetDocumentRegistry());
|
|
|
|
documents.Should().ContainSingle().Which.Text.Should().Be("foreign-schema-alias");
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotEndpoint_SyncsCompatibleForeignSchemaIdIntoLocalCache()
|
|
{
|
|
var key = new CultRecordKey("mesh-note:snapshot-sync-foreign-schema");
|
|
var snapshot = new CultNetSnapshotResponseRawMessage
|
|
{
|
|
MessageId = "foreign-schema-sync",
|
|
Documents = new[]
|
|
{
|
|
new CultNetRawDocumentRecord
|
|
{
|
|
SchemaId = "runtime.generated.mesh-note.ui.84",
|
|
RecordKey = key.Value,
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
PayloadEncoding = "messagepack",
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "foreign-schema-synced",
|
|
Revision = 45
|
|
},
|
|
typeof(MeshNoteDocument))
|
|
}
|
|
}
|
|
};
|
|
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(
|
|
typeof(MeshNoteDocument),
|
|
typeof(MeshNoteDocument));
|
|
var registry = CultMesh.CreateCultNetDocumentRegistry(
|
|
new[] { typeof(MeshNoteDocument), typeof(MeshNoteDocument) },
|
|
cacheRegistry);
|
|
var endpoint = CultMesh.SnapshotEndpoint(
|
|
"cultnet://foreign-schema.test:3075",
|
|
new CultMeshSnapshotEndpointOptions
|
|
{
|
|
DocumentRegistry = registry,
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
{
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
{
|
|
snapshot.MessageId = request.MessageId;
|
|
return snapshot;
|
|
})
|
|
}
|
|
});
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-foreign-schema-sync-{Guid.NewGuid():N}.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = cacheRegistry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
});
|
|
|
|
var synced = await endpoint.SyncDocumentAsync<MeshNoteDocument>(node, key.Value);
|
|
|
|
synced.Text.Should().Be("foreign-schema-synced");
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("foreign-schema-synced");
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(45);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotFacade_SyncsRemoteDocumentIntoLocalNode()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
var key = new CultRecordKey("mesh-note:facade-sync-alias");
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "facade-synced",
|
|
Revision = 46
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-facade-sync-alias-{Guid.NewGuid():N}.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = sourceCache.Registry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
});
|
|
|
|
var synced = await CultMesh.SyncDocumentFromPeerSnapshotAsync<MeshNoteDocument>(
|
|
node,
|
|
() => new MeshSnapshotSchemaClient(request =>
|
|
{
|
|
requests.Add(request);
|
|
return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request);
|
|
}),
|
|
"cultnet://facade-sync.test:3075",
|
|
key.Value,
|
|
new CultMeshSnapshotEndpointOptions
|
|
{
|
|
DocumentRegistry = registry,
|
|
Context = CultMesh.Verse("starbridge", "unity-raven").Context
|
|
});
|
|
var local = await node.Document<MeshNoteDocument>(key.Value, CultMesh.Verse("starbridge", "unity-raven"))
|
|
.LatestAsync();
|
|
|
|
synced.Text.Should().Be("facade-synced");
|
|
synced.Revision.Should().Be(46);
|
|
local.Text.Should().Be("facade-synced");
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(46);
|
|
requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotSession_ReusesOneConnectedClientAcrossOrderedRequests()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
var firstKey = new CultRecordKey("mesh-note:session-first");
|
|
var secondKey = new CultRecordKey("mesh-note:session-second");
|
|
await sourceCache.UpsertAsync(
|
|
new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "first", Revision = 1 },
|
|
new CultRecordHandle<MeshNoteDocument>(firstKey));
|
|
await sourceCache.UpsertAsync(
|
|
new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "second", Revision = 2 },
|
|
new CultRecordHandle<MeshNoteDocument>(secondKey));
|
|
var client = new MeshSnapshotSchemaClient(request =>
|
|
registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request));
|
|
|
|
using var session = CultMesh.SnapshotSession(
|
|
"cultnet://session.test:3075",
|
|
new CultMeshSnapshotRequestOptions { CreateClient = () => client },
|
|
registry);
|
|
var first = await session.FetchDocumentsAsync<MeshNoteDocument>(new[] { firstKey.Value });
|
|
var second = await session.FetchDocumentsAsync<MeshNoteDocument>(new[] { secondKey.Value });
|
|
|
|
client.ConnectCount.Should().Be(1);
|
|
first.Should().ContainSingle().Which.Text.Should().Be("first");
|
|
second.Should().ContainSingle().Which.Text.Should().Be("second");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentRegistryHelpers_CreateTypedNetworkRegistriesForAliases()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(
|
|
typeof(MeshNoteDocument),
|
|
typeof(MeshNoteDocument));
|
|
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(
|
|
new[] { typeof(MeshNoteDocument), typeof(MeshNoteDocument) },
|
|
cacheRegistry);
|
|
var key = new CultRecordKey("mesh-note:registry-helper");
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "registry-helper",
|
|
Revision = 23
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var endpoint = "cultnet://registry-helper.test:3077";
|
|
|
|
var documents = await CultMesh.FetchSnapshotDocumentsAsync<MeshNoteDocument>(
|
|
endpoint,
|
|
new CultMeshSnapshotRequestOptions
|
|
{
|
|
Selection = new CultNetSelection { Keys = new[] { key.Value } },
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
networkRegistry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request))
|
|
},
|
|
networkRegistry);
|
|
|
|
var schemaId = cacheRegistry.GetRequired<MeshNoteDocument>().SchemaId;
|
|
cacheRegistry.GetRequired<MeshNoteDocument>().SchemaId.Should().Be(schemaId);
|
|
networkRegistry.GetByDocumentType(typeof(MeshNoteDocument)).Should().NotBeNull();
|
|
networkRegistry.GetByDocumentType(typeof(MeshNoteDocument)).Should().NotBeNull();
|
|
networkRegistry.GetBySchemaId(schemaId)!.DocumentType.Should().Be(typeof(MeshNoteDocument));
|
|
documents.Should().ContainSingle().Which.Text.Should().Be("registry-helper");
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotEndpoint_ProvidesTypedHandles()
|
|
{
|
|
var sourceCache = new CultCache();
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
var key = new CultRecordKey("mesh-note:snapshot-endpoint");
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "snapshot-endpoint",
|
|
Revision = 19
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
var endpoint = "cultnet://snapshot-endpoint.test:3076";
|
|
var surface = CultMesh.SnapshotEndpoint(
|
|
endpoint,
|
|
new CultMeshSnapshotEndpointOptions
|
|
{
|
|
Context = CultMesh.Verse("starbridge", "browser-starfire").Context,
|
|
DocumentRegistry = registry,
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
{
|
|
ShardId = "primary",
|
|
ShardEpoch = 9,
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
{
|
|
requests.Add(request);
|
|
return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request);
|
|
})
|
|
}
|
|
});
|
|
|
|
var fetched = await surface.FetchDocumentAsync<MeshNoteDocument>(key.Value);
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-snapshot-endpoint-{Guid.NewGuid():N}.ccmp"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
StartServer = false,
|
|
CacheOptions = new CultCacheOpenOptions
|
|
{
|
|
Registry = sourceCache.Registry
|
|
},
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
DocumentRegistry = registry
|
|
}
|
|
});
|
|
var syncedDocument = await surface.SyncDocumentAsync<MeshNoteDocument>(node, key.Value);
|
|
var endpointHandle = surface.Document<MeshNoteDocument>(key.Value);
|
|
var endpointLatest = await endpointHandle.LatestAsync();
|
|
var catalog = surface.Documents(
|
|
CultMesh.SnapshotDocument<MeshNoteDocument>(key.Value, "daemon:mesh-note:snapshot-endpoint"));
|
|
var catalogLatest = await catalog.LatestAsync<MeshNoteDocument>();
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "snapshot-endpoint-synced",
|
|
Revision = 20
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var syncedEndpoint = surface.SyncTo(node);
|
|
var syncedHandle = syncedEndpoint.Document<MeshNoteDocument>(key.Value);
|
|
var syncedLatest = await syncedHandle.LatestAsync();
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "snapshot-endpoint-catalog-synced",
|
|
Revision = 21
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
var syncedCatalog = syncedEndpoint.Documents(
|
|
CultMesh.SnapshotDocument<MeshNoteDocument>(key.Value));
|
|
var syncedCatalogLatest = await syncedCatalog.LatestAsync<MeshNoteDocument>();
|
|
|
|
fetched.Text.Should().Be("snapshot-endpoint");
|
|
syncedDocument.Text.Should().Be("snapshot-endpoint");
|
|
endpointLatest.Revision.Should().Be(19);
|
|
catalog.Document<MeshNoteDocument>().DocumentId.Should().Be("daemon:mesh-note:snapshot-endpoint");
|
|
catalogLatest.Text.Should().Be("snapshot-endpoint");
|
|
syncedLatest.Text.Should().Be("snapshot-endpoint-synced");
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(21);
|
|
syncedCatalogLatest.Text.Should().Be("snapshot-endpoint-catalog-synced");
|
|
requests.Should().HaveCount(6);
|
|
requests.Should().OnlyContain(request =>
|
|
request.SchemaIds == null &&
|
|
request.RecordKeys!.SequenceEqual(new[] { key.Value }) &&
|
|
request.ShardId == "primary" &&
|
|
request.ShardEpoch == 9 &&
|
|
request.MessageId.StartsWith("cultmesh:browser-starfire:snapshot:", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Test]
|
|
public async Task CollectionHandle_ReadsAndWatchesDatabaseCollectionsByIndex()
|
|
{
|
|
var cache = new CultCache();
|
|
var database = new CultNetDatabase(cache);
|
|
var teamId = Guid.NewGuid().ToString("D");
|
|
var collection = CultMesh.CollectionByIndex<MeshIndexedPlayer>(
|
|
database,
|
|
"TeamId",
|
|
teamId);
|
|
var changes = new List<CultMeshCollectionChange<MeshIndexedPlayer>>();
|
|
using var subscription = collection.WatchChanges(change => changes.Add(change));
|
|
|
|
await database.PutAsync(
|
|
new CultRecordKey("player:one"),
|
|
new MeshIndexedPlayer
|
|
{
|
|
Name = "One",
|
|
TeamId = teamId,
|
|
Score = 10
|
|
});
|
|
await database.PutAsync(
|
|
new CultRecordKey("player:two"),
|
|
new MeshIndexedPlayer
|
|
{
|
|
Name = "Two",
|
|
TeamId = Guid.NewGuid().ToString("D"),
|
|
Score = 20
|
|
});
|
|
|
|
collection.CollectionId.Should().Contain(teamId);
|
|
collection.SchemaName.Should().Be("tests.mesh_indexed_player");
|
|
collection.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic);
|
|
collection.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(collection.SchemaId);
|
|
|
|
var snapshot = await collection.LatestAsync();
|
|
snapshot.Should().ContainSingle().Which.Name.Should().Be("One");
|
|
changes.Should().ContainSingle();
|
|
changes[0].Kind.Should().Be(CultMeshCollectionChangeKind.Added);
|
|
changes[0].Document!.Name.Should().Be("One");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CollectionHandle_IsIndexedInItsCatalogByTypeAndSchema()
|
|
{
|
|
var cache = new CultCache();
|
|
await cache.UpsertAsync(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "collection-alias",
|
|
Revision = 4
|
|
},
|
|
new CultRecordHandle<MeshNoteDocument>(new CultRecordKey("mesh-note:collection")));
|
|
|
|
var collection = CultMesh.Collection<MeshNoteDocument>(cache);
|
|
var catalog = CultMesh.Collections(collection);
|
|
|
|
catalog.Collections.Should().ContainSingle().Which.Should().BeSameAs(collection);
|
|
catalog.TryGetCollection<MeshNoteDocument>(out var byType).Should().BeTrue();
|
|
byType.Should().BeSameAs(collection);
|
|
catalog.TryGetCollection<MeshOtherDocument>(out _).Should().BeFalse();
|
|
catalog.TryGetCollectionBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue();
|
|
byVersion.Should().BeSameAs(collection);
|
|
catalog.CollectionBySchema("tests.mesh_note").Should().BeSameAs(collection);
|
|
catalog.CollectionBySchema(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId)
|
|
.Should()
|
|
.BeSameAs(collection);
|
|
|
|
var snapshot = await collection.LatestAsync();
|
|
snapshot.Should().ContainSingle().Which.Text.Should().Be("collection-alias");
|
|
var catalogSnapshot = await catalog.LatestAsync<MeshNoteDocument>();
|
|
catalogSnapshot.Should().ContainSingle().Which.Revision.Should().Be(4);
|
|
|
|
CultMeshCollectionChange<MeshNoteDocument> observed = null!;
|
|
using var subscription = catalog.WatchChanges<MeshNoteDocument>(change => observed = change);
|
|
await cache.UpsertAsync(
|
|
new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "collection-alias-updated",
|
|
Revision = 5
|
|
},
|
|
new CultRecordHandle<MeshNoteDocument>(new CultRecordKey("mesh-note:collection")));
|
|
|
|
observed.Kind.Should().Be(CultMeshCollectionChangeKind.Updated);
|
|
observed.Document!.Text.Should().Be("collection-alias-updated");
|
|
observed.Document.Revision.Should().Be(5);
|
|
observed.PreviousDocument!.Text.Should().Be("collection-alias");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DocumentHandle_ReplacesThroughCultMeshNodeDatabase()
|
|
{
|
|
using var directory = new TemporaryDirectory();
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
Path.Combine(directory.Path, "mesh-node.cc"),
|
|
new CultMeshNodeOptions
|
|
{
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
RuntimeId = "node-runtime",
|
|
Shards = new[]
|
|
{
|
|
new CultNetShardDescriptor(
|
|
"mesh",
|
|
"node-runtime",
|
|
epoch: 1,
|
|
isPrimary: true,
|
|
schemaIds: new[] { CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId })
|
|
}
|
|
},
|
|
StartServer = false
|
|
});
|
|
var key = new CultRecordKey("mesh-note:node");
|
|
|
|
var handle = CultMesh.Document<MeshNoteDocument>(
|
|
node,
|
|
key,
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
MeshNoteDocument observed = null!;
|
|
using var subscription = handle.Watch(value => observed = value);
|
|
await handle.ReplaceAsync(new MeshNoteDocument
|
|
{
|
|
Schema = "tests.mesh_note.v1",
|
|
Text = "node-replaced",
|
|
Revision = 3
|
|
});
|
|
|
|
var snapshot = await handle.LatestAsync();
|
|
snapshot.Text.Should().Be("node-replaced");
|
|
observed.Text.Should().Be("node-replaced");
|
|
observed.Revision.Should().Be(3);
|
|
}
|
|
|
|
[Test]
|
|
public void SurfaceCatalog_DescribesTypedRuntimeSurfaces()
|
|
{
|
|
var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab");
|
|
var sources = new[]
|
|
{
|
|
CultMesh.ProjectionSource(
|
|
"daemon:aetheria.frame.latest.v1",
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1")
|
|
};
|
|
var query = new CultMeshQuerySurface<MeshViewportRequest, string[]>(
|
|
"aetheria.zone.objects.visible",
|
|
(_request, _context) => Task.FromResult(Array.Empty<string>()),
|
|
sources: sources,
|
|
routeHint: route);
|
|
var feed = CultMesh.LiveFeed<MeshViewportRequest, string>(
|
|
"aetheria.rts.viewport.feed",
|
|
(_request, _context) => Task.FromResult("frame"),
|
|
sources: sources,
|
|
routeHint: route);
|
|
var operation = new CultMeshOperationHandle<MeshMoveRequest, CultMeshOperationReceipt>(
|
|
"aetheria.entity.pilot.move",
|
|
(_request, _context) => Task.FromResult(new CultMeshOperationReceipt("aetheria.entity.pilot.move", true)));
|
|
var pointer = CultMesh.StatePointer(
|
|
"aetheria.selection.current",
|
|
async () => "entity:ship:1",
|
|
() => new Subject<string>(),
|
|
route,
|
|
sources);
|
|
var document = CultMesh.Document(
|
|
"daemon:aetheria.frame.latest.v1",
|
|
CultMesh.Verse("aetheria.local", "unity-raven").Context,
|
|
_context => Task.FromResult(new MeshNoteDocument { Schema = "frame", Text = "current", Revision = 1 }),
|
|
_context => new Subject<MeshNoteDocument>(),
|
|
sources,
|
|
route);
|
|
var collection = new CultMeshCollectionHandle<MeshNoteDocument>(
|
|
"daemon:aetheria.contacts.v1",
|
|
() => Task.FromResult<IReadOnlyList<MeshNoteDocument>>(Array.Empty<MeshNoteDocument>()),
|
|
() => new Subject<CultMeshCollectionChange<MeshNoteDocument>>(),
|
|
sources,
|
|
route);
|
|
var nativeView = new CultMeshNativeSliceViewDescriptor(
|
|
"aetheria.zone.render",
|
|
"gamecult.aetheria.render_body.v1",
|
|
rowCount: 128,
|
|
new[] { CultMeshNativeSliceColumn.For<MeshVec2>("position") },
|
|
route);
|
|
|
|
var catalog = CultMesh.DescribeSurfaceCatalog(
|
|
"gamecult.aetheria.rts.surfaces.v1",
|
|
new[]
|
|
{
|
|
CultMesh.DescribeSurface(query),
|
|
CultMesh.DescribeSurface(feed),
|
|
CultMesh.DescribeSurface(operation),
|
|
CultMesh.DescribeSurface(document),
|
|
CultMesh.DescribeSurface(collection),
|
|
CultMesh.DescribeSurface(pointer),
|
|
CultMesh.DescribeSurface(nativeView)
|
|
});
|
|
|
|
catalog.CatalogId.Should().Be("gamecult.aetheria.rts.surfaces.v1");
|
|
catalog.Surfaces.Should().HaveCount(7);
|
|
catalog.Surfaces[0].Kind.Should().Be(CultMeshSurfaceKind.Query);
|
|
catalog.Surfaces[0].SurfaceId.Should().Be("aetheria.zone.objects.visible");
|
|
catalog.Surfaces[0].RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
catalog.Surfaces[0].Sources.Should().HaveCount(1);
|
|
catalog.Find("aetheria.rts.viewport.feed")!.Kind.Should().Be(CultMeshSurfaceKind.LiveFeed);
|
|
catalog.Find("aetheria.entity.pilot.move")!.Kind.Should().Be(CultMeshSurfaceKind.Operation);
|
|
catalog.Find("daemon:aetheria.frame.latest.v1")!.Kind.Should().Be(CultMeshSurfaceKind.Document);
|
|
catalog.Find("daemon:aetheria.contacts.v1")!.Kind.Should().Be(CultMeshSurfaceKind.Collection);
|
|
catalog.Find("aetheria.selection.current")!.Kind.Should().Be(CultMeshSurfaceKind.StatePointer);
|
|
catalog.Find("aetheria.selection.current")!.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
catalog.Find("aetheria.selection.current")!.Sources.Should().ContainSingle();
|
|
catalog.Find("aetheria.zone.render")!.Kind.Should().Be(CultMeshSurfaceKind.NativeSliceView);
|
|
catalog.Find("aetheria.zone.render")!.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
catalog.Find("missing").Should().BeNull();
|
|
catalog.FindByKind(CultMeshSurfaceKind.Operation)
|
|
.Should()
|
|
.ContainSingle()
|
|
.Which
|
|
.SurfaceId
|
|
.Should()
|
|
.Be("aetheria.entity.pilot.move");
|
|
catalog.FindByKind(CultMeshSurfaceKind.Query).Should().ContainSingle();
|
|
|
|
var index = CultMesh.DescribeSurfaceCatalogIndex(catalog);
|
|
index.CatalogId.Should().Be(catalog.CatalogId);
|
|
index.Queries.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.zone.objects.visible");
|
|
index.LiveFeeds.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.rts.viewport.feed");
|
|
index.Operations.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.entity.pilot.move");
|
|
index.Documents.Should().ContainSingle().Which.SurfaceId.Should().Be("daemon:aetheria.frame.latest.v1");
|
|
index.Collections.Should().ContainSingle().Which.SurfaceId.Should().Be("daemon:aetheria.contacts.v1");
|
|
index.StatePointers.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.selection.current");
|
|
index.NativeSliceViews.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.zone.render");
|
|
index.ProjectionRecipes.Should().BeEmpty();
|
|
}
|
|
|
|
[Test]
|
|
public async Task PollingQueryWatcher_TurnsSnapshotsIntoReactiveFeed()
|
|
{
|
|
var frameId = 0;
|
|
var observed = new List<int>();
|
|
var feed = CultMesh.LiveFeed<MeshViewportRequest, int>(
|
|
"aetheria.rts.viewport.feed",
|
|
(_request, _context) => Task.FromResult(frameId),
|
|
CultMesh.PollingQueryWatcher<MeshViewportRequest, int>(
|
|
(_request, _context) => Task.FromResult(frameId),
|
|
new CultMeshPollingWatchOptions<int>(TimeSpan.FromMilliseconds(5))));
|
|
|
|
using (feed
|
|
.Watch(new MeshViewportRequest(-1, 1), CultMeshQueryContext.ForRuntime("browser-starfire"))
|
|
.Subscribe(value => observed.Add(value)))
|
|
{
|
|
await Task.Delay(35);
|
|
frameId = 1;
|
|
await Task.Delay(35);
|
|
frameId = 1;
|
|
await Task.Delay(25);
|
|
frameId = 2;
|
|
await Task.Delay(35);
|
|
}
|
|
|
|
frameId = 3;
|
|
await Task.Delay(25);
|
|
|
|
observed.Should().Equal(0, 1, 2);
|
|
}
|
|
|
|
[Test]
|
|
public void NativeSliceDescriptor_DescribesSharedColumnsWithoutOpeningTransport()
|
|
{
|
|
var descriptor = new CultMeshNativeSliceViewDescriptor(
|
|
"aetheria.zone.render",
|
|
"gamecult.aetheria.render_body.v1",
|
|
rowCount: 128,
|
|
new[]
|
|
{
|
|
CultMeshNativeSliceColumn.For<MeshVec2>("position"),
|
|
CultMeshNativeSliceColumn.For<MeshVec2>("velocity")
|
|
},
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "CultCache slab"),
|
|
nativeHandle: "cultcache-slab:aetheria-zone-render");
|
|
|
|
descriptor.Route.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
descriptor.Columns.Should().HaveCount(2);
|
|
descriptor.Columns[0].Name.Should().Be("position");
|
|
descriptor.DenseRowStrideBytes.Should().Be(16);
|
|
descriptor.FindColumn("velocity")!.ElementSizeBytes.Should().Be(8);
|
|
|
|
var diagnostic = CultMesh.DescribeNativeSliceView(descriptor);
|
|
diagnostic.ViewId.Should().Be("aetheria.zone.render");
|
|
diagnostic.SchemaId.Should().Be("gamecult.aetheria.render_body.v1");
|
|
diagnostic.RowCount.Should().Be(128);
|
|
diagnostic.Route.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
diagnostic.NativeHandle.Should().Be("cultcache-slab:aetheria-zone-render");
|
|
diagnostic.DenseRowStrideBytes.Should().Be(16);
|
|
diagnostic.Columns.Should().HaveCount(2);
|
|
diagnostic.Columns.Should().NotBeSameAs(descriptor.Columns);
|
|
}
|
|
|
|
[Test]
|
|
public void NegotiatesGpuTextureStreamsWithoutForcingCopies()
|
|
{
|
|
var catalog = CultMesh.CreateStreamCatalog();
|
|
var stream = new CultMeshStreamDescriptor(
|
|
"mimir:kiyo-pro:rgba",
|
|
"mimir-live",
|
|
"starfire",
|
|
CultMeshStreamKind.Video,
|
|
new CultMeshStreamClock("mimir:clock", "kiyo-pro", sampleRate: 90_000, confidence: 0.92d),
|
|
new[]
|
|
{
|
|
CultMeshStreamBodyTransport.SharedD3D12Texture,
|
|
CultMeshStreamBodyTransport.SharedMemory,
|
|
CultMeshStreamBodyTransport.CultCachePage
|
|
},
|
|
video: new CultMeshVideoStreamFormat(1920, 1080, "rgba8", framesPerSecond: 60),
|
|
maxInFlightFrames: 4);
|
|
|
|
catalog.Declare(stream);
|
|
|
|
var negotiation = catalog.Negotiate(
|
|
stream.StreamId,
|
|
new CultMeshStreamConsumerProfile(
|
|
"fensalir",
|
|
"mimir-live",
|
|
new[] { CultMeshStreamBodyTransport.SharedD3D12Texture, CultMeshStreamBodyTransport.CultCachePage },
|
|
acceptedKinds: new[] { CultMeshStreamKind.Video },
|
|
canImportGpuHandles: true,
|
|
maxInFlightFrames: 2));
|
|
|
|
negotiation.Transport.Should().Be(CultMeshStreamBodyTransport.SharedD3D12Texture);
|
|
negotiation.CopyBudget.Should().Be(CultMeshStreamCopyBudget.ZeroCopyTarget);
|
|
negotiation.MaxInFlightFrames.Should().Be(2);
|
|
|
|
var handle = new CultMeshStreamFrameHandle(
|
|
stream.StreamId,
|
|
sequence: 42,
|
|
timestampNs: 123_456_789,
|
|
CultMeshStreamBodyTransport.SharedD3D12Texture,
|
|
nativeHandle: "shared-handle:0xfeedbeef",
|
|
fenceHandle: "fence:0x1234",
|
|
fenceValue: 7);
|
|
|
|
catalog.PublishFrame(handle);
|
|
|
|
catalog.LatestFrame(stream.StreamId).Should().BeSameAs(handle);
|
|
}
|
|
|
|
[Test]
|
|
public void NegotiationRejectsStreamsWithoutACommonTransport()
|
|
{
|
|
var catalog = CultMesh.CreateStreamCatalog();
|
|
catalog.Declare(new CultMeshStreamDescriptor(
|
|
"mimir:kiyo-pro:gpu-only",
|
|
"mimir-live",
|
|
"starfire",
|
|
CultMeshStreamKind.Video,
|
|
new CultMeshStreamClock("mimir:clock", "kiyo-pro"),
|
|
new[] { CultMeshStreamBodyTransport.SharedD3D12Texture }));
|
|
|
|
var consumer = new CultMeshStreamConsumerProfile(
|
|
"cpu-recorder",
|
|
"mimir-live",
|
|
new[] { CultMeshStreamBodyTransport.SharedMemory },
|
|
acceptedKinds: new[] { CultMeshStreamKind.Video });
|
|
|
|
Action act = () => catalog.Negotiate("mimir:kiyo-pro:gpu-only", consumer);
|
|
|
|
act.Should().Throw<InvalidOperationException>()
|
|
.WithMessage("Stream and consumer have no compatible body transport.");
|
|
}
|
|
|
|
[Test]
|
|
public void SharedMemoryRingPublishesWritableSlotsWithoutInternalCopies()
|
|
{
|
|
var catalog = CatalogWithByteStream();
|
|
using var ring = catalog.CreateSharedMemoryRing("mimir:leap:depth", slotCount: 3, slotByteLength: 16);
|
|
|
|
ring.TryAcquireWriteSlot(out var write).Should().BeTrue();
|
|
ReadOnlySpan<byte> seed = stackalloc byte[] { 1, 2, 3, 4 };
|
|
seed.CopyTo(write.Span);
|
|
|
|
var handle = ring.CommitWriteSlot(write, timestampNs: 99, byteLength: 4);
|
|
catalog.PublishFrame(handle);
|
|
|
|
ring.TryAcquireLatestRead(out var read).Should().BeTrue();
|
|
using (read)
|
|
{
|
|
read.Handle.Sequence.Should().Be(0);
|
|
read.Handle.UnavoidableCopyCount.Should().Be(0);
|
|
read.Span.ToArray().Should().Equal(1, 2, 3, 4);
|
|
}
|
|
|
|
var stats = ring.Stats();
|
|
stats.PublishedFrames.Should().Be(1);
|
|
stats.UnavoidableCopyCount.Should().Be(0);
|
|
catalog.LatestFrame("mimir:leap:depth")!.ResourceKey.Should().Be("mimir:leap:depth:slot:0");
|
|
}
|
|
|
|
[Test]
|
|
public void SharedMemoryRingDoesNotOverwriteSlotsHeldByReaders()
|
|
{
|
|
var catalog = CatalogWithByteStream();
|
|
using var ring = catalog.CreateSharedMemoryRing("mimir:leap:depth", slotCount: 3, slotByteLength: 8);
|
|
|
|
ring.TryAcquireWriteSlot(out var firstWrite).Should().BeTrue();
|
|
firstWrite.Span[0] = 11;
|
|
ring.CommitWriteSlot(firstWrite, timestampNs: 1, byteLength: 1);
|
|
|
|
ring.TryAcquireLatestRead(out var read).Should().BeTrue();
|
|
|
|
ring.TryAcquireWriteSlot(out var secondWrite).Should().BeTrue();
|
|
ring.CommitWriteSlot(secondWrite, timestampNs: 2, byteLength: 1);
|
|
ring.TryAcquireWriteSlot(out var thirdWrite).Should().BeTrue();
|
|
ring.CommitWriteSlot(thirdWrite, timestampNs: 3, byteLength: 1);
|
|
ring.TryAcquireLatestRead(out var thirdRead).Should().BeTrue();
|
|
ring.TryAcquireWriteSlot(out var overwriteSecond).Should().BeTrue();
|
|
ring.CommitWriteSlot(overwriteSecond, timestampNs: 4, byteLength: 1);
|
|
ring.TryAcquireLatestRead(out var secondRead).Should().BeTrue();
|
|
ring.TryAcquireWriteSlot(out _).Should().BeFalse();
|
|
ring.Stats().BlockedWrites.Should().Be(1);
|
|
|
|
read.Dispose();
|
|
thirdRead.Dispose();
|
|
secondRead.Dispose();
|
|
|
|
ring.TryAcquireWriteSlot(out var fifthWrite).Should().BeTrue();
|
|
fifthWrite.Span[0] = 12;
|
|
ring.CommitWriteSlot(fifthWrite, timestampNs: 5, byteLength: 1);
|
|
|
|
var stats = ring.Stats();
|
|
stats.PublishedFrames.Should().Be(5);
|
|
stats.DroppedFrames.Should().Be(2);
|
|
stats.LatestSequence.Should().Be(4);
|
|
}
|
|
|
|
[Test]
|
|
public void CopyPublishMarksFallbackCopiesExplicitly()
|
|
{
|
|
var catalog = CatalogWithByteStream();
|
|
using var ring = catalog.CreateSharedMemoryRing("mimir:leap:depth", slotCount: 3, slotByteLength: 8);
|
|
|
|
ring.TryPublishCopy(stackalloc byte[] { 5, 6, 7 }, timestampNs: 10, durationNs: 2, out var handle)
|
|
.Should()
|
|
.BeTrue();
|
|
|
|
handle.UnavoidableCopyCount.Should().Be(1);
|
|
ring.Stats().UnavoidableCopyCount.Should().Be(1);
|
|
}
|
|
|
|
private static CultMeshStreamCatalog CatalogWithByteStream()
|
|
{
|
|
var catalog = CultMesh.CreateStreamCatalog();
|
|
catalog.Declare(new CultMeshStreamDescriptor(
|
|
"mimir:leap:depth",
|
|
"mimir-live",
|
|
"starfire",
|
|
CultMeshStreamKind.Tensor,
|
|
new CultMeshStreamClock("mimir:clock", "leap", confidence: 0.8d),
|
|
new[] { CultMeshStreamBodyTransport.SharedMemory, CultMeshStreamBodyTransport.CultCachePage }));
|
|
return catalog;
|
|
}
|
|
|
|
private static async Task WaitForAsync(Func<bool> predicate)
|
|
{
|
|
var deadline = DateTimeOffset.UtcNow.AddSeconds(2);
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
if (predicate())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
predicate().Should().BeTrue();
|
|
}
|
|
|
|
private sealed class TemporaryDirectory : IDisposable
|
|
{
|
|
public TemporaryDirectory()
|
|
{
|
|
Path = System.IO.Path.Combine(
|
|
System.IO.Path.GetTempPath(),
|
|
"cultmesh-tests-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(Path);
|
|
}
|
|
|
|
public string Path { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(Path))
|
|
{
|
|
Directory.Delete(Path, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
[CultDocument("tests.mesh_indexed_player", "tests.mesh_indexed_player.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
internal sealed class MeshIndexedPlayer
|
|
{
|
|
[Key(0)]
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
[Key(1)]
|
|
[CultIndex]
|
|
public string TeamId { get; set; } = string.Empty;
|
|
|
|
[Key(2)]
|
|
public int Score { get; set; }
|
|
}
|
|
|
|
[CultDocument("tests.mesh_note", "tests.mesh_note.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
internal sealed class MeshNoteDocument
|
|
{
|
|
[Key(0)]
|
|
public string Schema { get; set; } = string.Empty;
|
|
|
|
[Key(1)]
|
|
public string Text { get; set; } = string.Empty;
|
|
|
|
[Key(2)]
|
|
public int Revision { get; set; }
|
|
}
|
|
|
|
[CultDocument("tests.mesh_publication_note", "tests.mesh_publication_note.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
internal sealed class MeshPublicationNoteDocument
|
|
{
|
|
[Key(0)]
|
|
public string Schema { get; set; } = string.Empty;
|
|
|
|
[Key(1)]
|
|
public string Text { get; set; } = string.Empty;
|
|
|
|
[Key(2)]
|
|
public int Revision { get; set; }
|
|
}
|
|
|
|
[CultDocument("tests.mesh_other", "tests.mesh_other.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
internal sealed class MeshOtherDocument
|
|
{
|
|
[Key(0)]
|
|
public string Schema { get; set; } = string.Empty;
|
|
|
|
[Key(1)]
|
|
public string Text { get; set; } = string.Empty;
|
|
}
|
|
}
|
|
|
|
public readonly struct MeshMoveRequest
|
|
{
|
|
public MeshMoveRequest(int entityId, float x, float y)
|
|
{
|
|
EntityId = entityId;
|
|
X = x;
|
|
Y = y;
|
|
}
|
|
|
|
public int EntityId { get; }
|
|
public float X { get; }
|
|
public float Y { get; }
|
|
}
|
|
|
|
public readonly struct MeshViewportRequest
|
|
{
|
|
public MeshViewportRequest(float minX, float maxX)
|
|
{
|
|
MinX = minX;
|
|
MaxX = maxX;
|
|
}
|
|
|
|
public float MinX { get; }
|
|
public float MaxX { get; }
|
|
}
|
|
|
|
public readonly struct MeshVec2
|
|
{
|
|
public MeshVec2(float x, float y)
|
|
{
|
|
X = x;
|
|
Y = y;
|
|
}
|
|
|
|
public float X { get; }
|
|
public float Y { get; }
|
|
}
|
|
|
|
public sealed class MeshAetheriaDomain
|
|
{
|
|
private readonly CultMeshVerseContext _context;
|
|
|
|
public MeshAetheriaDomain(CultMeshVerseContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public MeshEntityFacade Entity(int entityId)
|
|
{
|
|
return new MeshEntityFacade(_context, entityId);
|
|
}
|
|
|
|
public MeshZoneFacade Zone(string zoneId)
|
|
{
|
|
return new MeshZoneFacade(_context, zoneId);
|
|
}
|
|
}
|
|
|
|
public sealed class MeshEntityFacade
|
|
{
|
|
public MeshEntityFacade(CultMeshVerseContext context, int entityId)
|
|
{
|
|
Pilot = new MeshPilotFacade(context, entityId);
|
|
}
|
|
|
|
public MeshPilotFacade Pilot { get; }
|
|
}
|
|
|
|
public sealed class MeshPilotFacade
|
|
{
|
|
private readonly int _entityId;
|
|
private readonly CultMeshBoundOperationHandle<MeshMoveRequest, CultMeshOperationReceipt> _move;
|
|
|
|
public MeshPilotFacade(CultMeshVerseContext context, int entityId)
|
|
{
|
|
_entityId = entityId;
|
|
_move = CultMesh.BindOperation(
|
|
context,
|
|
new CultMeshOperationHandle<MeshMoveRequest, CultMeshOperationReceipt>(
|
|
"aetheria.entity.pilot.move",
|
|
(request, context) => Task.FromResult(new CultMeshOperationReceipt(
|
|
"aetheria.entity.pilot.move",
|
|
accepted: request.EntityId == _entityId &&
|
|
Math.Abs(request.X) > 0 &&
|
|
context.Claims.Any(claim => claim.Role == "pilot-control"),
|
|
context.RouteHint))));
|
|
}
|
|
|
|
public Task<CultMeshOperationReceipt> MoveAsync(MeshVec2 direction, string idempotencyKey)
|
|
{
|
|
return _move.InvokeAsync(
|
|
new MeshMoveRequest(_entityId, direction.X, direction.Y),
|
|
idempotencyKey);
|
|
}
|
|
}
|
|
|
|
public sealed class MeshZoneFacade
|
|
{
|
|
public MeshZoneFacade(CultMeshVerseContext context, string zoneId)
|
|
{
|
|
Objects = new MeshObjectsFacade(context, zoneId);
|
|
}
|
|
|
|
public MeshObjectsFacade Objects { get; }
|
|
}
|
|
|
|
public sealed class MeshObjectsFacade
|
|
{
|
|
private readonly string _zoneId;
|
|
private readonly CultMeshBoundQuerySurface<MeshViewportRequest, string[]> _visibleObjects;
|
|
|
|
public MeshObjectsFacade(CultMeshVerseContext context, string zoneId)
|
|
{
|
|
_zoneId = zoneId;
|
|
_visibleObjects = CultMesh.BindQuery(
|
|
context,
|
|
new CultMeshQuerySurface<MeshViewportRequest, string[]>(
|
|
"aetheria.zone.objects.visible",
|
|
(parameters, context) => Task.FromResult(new[]
|
|
{
|
|
$"{context.RuntimeId}:{_zoneId}:{parameters.MinX}:{parameters.MaxX}:{context.RouteHint.Kind}"
|
|
})));
|
|
}
|
|
|
|
public Task<string[]> VisibleWithinAsync(MeshViewportRequest request)
|
|
{
|
|
return _visibleObjects.ExecuteAsync(request);
|
|
}
|
|
}
|
|
|
|
internal sealed class MeshSnapshotSchemaClient : ICultNetSchemaClient
|
|
{
|
|
private readonly Func<CultNetSnapshotRequestMessage, CultNetSnapshotResponseRawMessage> _respond;
|
|
private readonly List<Action<CultNetSnapshotResponseRawMessage>> _snapshotHandlers = new();
|
|
private readonly List<Action<CultNetErrorMessage>> _errorHandlers = new();
|
|
|
|
public MeshSnapshotSchemaClient(
|
|
Func<CultNetSnapshotRequestMessage, CultNetSnapshotResponseRawMessage> respond)
|
|
{
|
|
_respond = respond;
|
|
}
|
|
|
|
public bool Connected { get; private set; }
|
|
public int ConnectCount { get; private set; }
|
|
|
|
public void Connect(string host, int port)
|
|
{
|
|
ConnectCount++;
|
|
Connected = true;
|
|
}
|
|
|
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
|
|
{
|
|
if (message is not CultNetSnapshotRequestMessage request)
|
|
{
|
|
foreach (var handler in _errorHandlers)
|
|
{
|
|
handler(new CultNetErrorMessage { Error = $"Unexpected message {typeof(T).Name}." });
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var response = _respond(request);
|
|
foreach (var handler in _snapshotHandlers)
|
|
{
|
|
handler(response);
|
|
}
|
|
}
|
|
|
|
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
{
|
|
if (typeof(T) == typeof(CultNetSnapshotResponseRawMessage))
|
|
{
|
|
_snapshotHandlers.Add(message => callback((T)(object)message));
|
|
}
|
|
else if (typeof(T) == typeof(CultNetErrorMessage))
|
|
{
|
|
_errorHandlers.Add(message => callback((T)(object)message));
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
internal sealed class FailedBackgroundSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth
|
|
{
|
|
private readonly bool _connected;
|
|
|
|
public FailedBackgroundSchemaClient(Exception error, bool connected = true)
|
|
{
|
|
_connected = connected;
|
|
BackgroundFailure = Task.FromResult(error);
|
|
}
|
|
|
|
public bool Connected { get; private set; }
|
|
public Task<Exception> BackgroundFailure { get; }
|
|
|
|
public void Connect(string host, int port) => Connected = _connected;
|
|
|
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
|
|
{
|
|
}
|
|
|
|
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
{
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
internal sealed class BlockingCountClock : ICultMeshClock
|
|
{
|
|
private int _delayCount;
|
|
|
|
public DateTimeOffset UtcNow => DateTimeOffset.UnixEpoch;
|
|
|
|
public int DelayCount => Volatile.Read(ref _delayCount);
|
|
|
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default)
|
|
{
|
|
Interlocked.Increment(ref _delayCount);
|
|
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
|
}
|
|
}
|