Sixteen conflicted files resolved by the spec's rule: main owns payload ownership, bounds and the Clock seam; C2a owns persistence semantics and exact binding. MintStoredAt is internal static (previous, now); the cache and the store pass their cache's Clock. Package versions are main's. The cultmesh-ts payload-parse acceptance and its test are deleted under retire-schema-alias; peer snapshot reads take the schema's own id, else an id the definition lists as compatible. The held firstRecordAtKey fallback is removed: main's handles take definitions only, so nothing could reach it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5676 lines
264 KiB
Python
5676 lines
264 KiB
Python
from __future__ import annotations
|
|
|
|
import errno
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
import msgpack
|
|
from cultcache_py import (
|
|
CultCache,
|
|
SingleFileMessagePackBackingStore,
|
|
define_database_entry_type,
|
|
define_document_type,
|
|
)
|
|
from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document
|
|
from cultmesh_py.verify import verify
|
|
from cultnet_py import (
|
|
compute_simulation_claim_hash,
|
|
CultNetClientAuthorityScope,
|
|
CultNetRawClient,
|
|
CultNetRawDocumentRecord,
|
|
CultNetRawSnapshotResponse,
|
|
CultNetSchemaCatalog,
|
|
CultNetShardCatalog,
|
|
CultNetShardDescriptor,
|
|
CultNetShardLogResponse,
|
|
CultNetFileShardReplicaCursorStore,
|
|
CultNetFileShardMutationLogStore,
|
|
CultNetInMemoryShardReplicaCursorStore,
|
|
CultNetSchemaWriteForwarder,
|
|
CultNetSchemaShardLogFetcher,
|
|
CultNetSchemaShardSnapshotFetcher,
|
|
CultNetPeerError,
|
|
CultNetShardReplicator,
|
|
CultNetShardReplicatorOptions,
|
|
CultNetShardMutationLogStore,
|
|
CultNetShardWriteForwarder,
|
|
CultNetSimulationConsensusOptions,
|
|
CultNetSimulationConsensusCandidate,
|
|
CultNetSimulationObservation,
|
|
CultNetSimulationObservationHub,
|
|
CultNetRudpPacketType,
|
|
CultNetRudpSendOptions,
|
|
CultNetRudpSession,
|
|
CultNetRudpSessionOptions,
|
|
CultNetRudpSocketMode,
|
|
CultNetRudpSocketTransportConnection,
|
|
CultNetRudpSocketTransportOptions,
|
|
apply_shard_log_response,
|
|
create_rudp_schema_transport,
|
|
decode_rudp_packet,
|
|
document_delete,
|
|
document_put_raw,
|
|
encode_rudp_packet,
|
|
hello,
|
|
simulation_observation,
|
|
snapshot_request,
|
|
)
|
|
from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST
|
|
from cultmesh_py import create_node
|
|
from cultmesh_py import (
|
|
CultMesh,
|
|
CultMeshDatabase,
|
|
CultMeshDatabaseChange,
|
|
CultMeshGameSessionOptions,
|
|
CultMeshNodeOptions,
|
|
CultMeshReactiveDocumentOptions,
|
|
CultMeshPeerCard,
|
|
CultMeshPeerCatalog,
|
|
CultMeshSimulationFact,
|
|
CultMeshAuthorityLease,
|
|
CultMeshAuthorityLeaseCatalog,
|
|
CultMeshHmacAuthorityLeaseVerifier,
|
|
CultMeshDiscoveryClient,
|
|
CultMeshPeerExchangeClient,
|
|
CultMeshPeerHealthMonitor,
|
|
CultMeshSimulationObservationFanout,
|
|
CultMeshSnapshotFanout,
|
|
CultMeshStreamCatalog,
|
|
CultMeshStreamConsumerProfile,
|
|
CultMeshStreamDescriptor,
|
|
CultMeshStreamFrameHandle,
|
|
CultMeshVerseCatalog,
|
|
CultMeshVerseCompatibility,
|
|
CultMeshVerseDescriptor,
|
|
CultMeshVerseDiscoveryClient,
|
|
READY_SCHEMA_VERSION,
|
|
peer_exchange_request,
|
|
simulation_fact_document,
|
|
verse_catalog_request,
|
|
)
|
|
|
|
|
|
def bind_udp_socket() -> socket.socket:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
sock.settimeout(0.02)
|
|
return sock
|
|
|
|
|
|
def pump_rudp_handshake(
|
|
client: CultNetRudpSocketTransportConnection,
|
|
server: CultNetRudpSocketTransportConnection,
|
|
) -> None:
|
|
for _ in range(20):
|
|
server.receive_once()
|
|
client.receive_once()
|
|
server.receive_once()
|
|
if client.connected and server.connected:
|
|
return
|
|
time.sleep(0.005)
|
|
raise AssertionError("RUDP socket handshake did not complete")
|
|
|
|
|
|
def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection):
|
|
for _ in range(20):
|
|
frame = transport.receive_once()
|
|
if frame is not None:
|
|
return frame
|
|
time.sleep(0.005)
|
|
raise AssertionError("RUDP socket frame was not delivered")
|
|
|
|
|
|
@dataclass
|
|
class Item:
|
|
name: str
|
|
category: str
|
|
value: int
|
|
|
|
|
|
class CultMeshTests(unittest.TestCase):
|
|
def _wait_until(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None:
|
|
deadline = time.monotonic() + timeout_seconds
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(0.01)
|
|
self.fail("Timed out waiting for condition")
|
|
|
|
def test_verify_reports_python_runtime_surface_health(self) -> None:
|
|
result = verify(records=4)
|
|
|
|
self.assertEqual(result["status"], "ok")
|
|
self.assertEqual(
|
|
{check["name"] for check in result["checks"]},
|
|
{
|
|
"public_exports",
|
|
"typed_markers",
|
|
"local_cultmesh_wire_smoke",
|
|
"cultmesh_capability_truth",
|
|
"benchmark_sanity",
|
|
},
|
|
)
|
|
by_name = {check["name"]: check for check in result["checks"]}
|
|
self.assertEqual(by_name["public_exports"]["missing"], {})
|
|
self.assertEqual(by_name["cultmesh_capability_truth"]["failures"], [])
|
|
|
|
def test_cultmesh_client_uses_rudp_schema_transport_endpoint(self) -> None:
|
|
import msgpack # type: ignore
|
|
|
|
server_socket = bind_udp_socket()
|
|
server_host, server_port = server_socket.getsockname()[:2]
|
|
connection_id = 0x43554C54
|
|
server = CultNetRudpSocketTransportConnection(
|
|
CultNetRudpSocketTransportOptions(
|
|
runtime_id="python-rudp-schema-server",
|
|
socket=server_socket,
|
|
mode=CultNetRudpSocketMode.SERVER,
|
|
connection_id=connection_id,
|
|
initial_sequence=100,
|
|
resend_delay_ms=25,
|
|
)
|
|
)
|
|
received_messages: list[dict[str, object]] = []
|
|
|
|
def serve_once() -> None:
|
|
deadline = time.monotonic() + 2.0
|
|
try:
|
|
while time.monotonic() < deadline:
|
|
frame = server.receive_once()
|
|
server.poll_resends()
|
|
if frame is None:
|
|
time.sleep(0.005)
|
|
continue
|
|
received = msgpack.unpackb(frame.payload, raw=False)
|
|
received_messages.append(received)
|
|
response = {
|
|
"schemaVersion": "cultnet.schema_catalog_response.v0",
|
|
"messageId": received["messageId"],
|
|
"schemas": [],
|
|
}
|
|
server.send("schema", msgpack.packb(response, use_bin_type=True))
|
|
return
|
|
raise AssertionError("RUDP schema server did not receive a request")
|
|
finally:
|
|
server.close()
|
|
|
|
server_thread = threading.Thread(target=serve_once, daemon=True)
|
|
server_thread.start()
|
|
client = CultMesh.create_client(
|
|
endpoint=f"rudp://{server_host}:{server_port}",
|
|
timeout_seconds=2.0,
|
|
connection_id=connection_id,
|
|
runtime_id="python-rudp-schema-client",
|
|
)
|
|
|
|
response = client.fetch_schema_catalog(message_id="rudp-transport-factory")
|
|
server_thread.join(timeout=2.0)
|
|
|
|
self.assertFalse(server_thread.is_alive())
|
|
self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0")
|
|
self.assertEqual(response["messageId"], "rudp-transport-factory")
|
|
self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0")
|
|
self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory")
|
|
|
|
def _rudp_client_bound_host(self, endpoint: str, bind_host: str | None = None) -> str:
|
|
client = CultMesh.create_rudp_client("python-cultmesh-bind-client", 0x10203060, endpoint, bind_host=bind_host)
|
|
try:
|
|
return client.socket.getsockname()[0]
|
|
finally:
|
|
client.socket.close()
|
|
|
|
# A client bound to loopback cannot send to another host on Windows, so a
|
|
# client for a remote endpoint binds the unspecified address of its family.
|
|
def test_cultmesh_rudp_client_for_a_remote_endpoint_binds_the_unspecified_address(self) -> None:
|
|
self.assertEqual(self._rudp_client_bound_host("rudp://10.77.0.1:17872"), "0.0.0.0")
|
|
self.assertEqual(self._rudp_client_bound_host("rudp://[2001:db8::1]:17872"), "::")
|
|
|
|
def test_cultmesh_rudp_client_for_a_loopback_endpoint_binds_loopback(self) -> None:
|
|
self.assertEqual(self._rudp_client_bound_host("rudp://127.0.0.1:17872"), "127.0.0.1")
|
|
self.assertEqual(self._rudp_client_bound_host("rudp://[::1]:17872"), "::1")
|
|
|
|
def test_cultmesh_rudp_client_binds_an_explicit_bind_host_whatever_the_endpoint(self) -> None:
|
|
self.assertEqual(self._rudp_client_bound_host("rudp://10.77.0.1:17872", "127.0.0.1"), "127.0.0.1")
|
|
self.assertEqual(self._rudp_client_bound_host("rudp://127.0.0.1:17872", "0.0.0.0"), "0.0.0.0")
|
|
|
|
def test_cultmesh_rudp_server_binds_loopback_unless_told_otherwise(self) -> None:
|
|
loopback = CultMesh.create_rudp_server("python-cultmesh-bind-server", 0x10203061)
|
|
anywhere = CultMesh.create_rudp_server("python-cultmesh-bind-server", 0x10203062, bind_host="0.0.0.0")
|
|
try:
|
|
self.assertEqual(loopback.socket.getsockname()[0], "127.0.0.1")
|
|
self.assertEqual(anywhere.socket.getsockname()[0], "0.0.0.0")
|
|
finally:
|
|
loopback.socket.close()
|
|
anywhere.socket.close()
|
|
|
|
def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None:
|
|
connection_id = 0x10203042
|
|
server = CultMesh.create_rudp_server(
|
|
"python-cultmesh-rudp-server",
|
|
connection_id,
|
|
initial_sequence=100,
|
|
resend_delay_ms=25,
|
|
max_fragment_bytes=1024,
|
|
max_pending_reliable_packets=16,
|
|
)
|
|
server_host, server_port = server.socket.getsockname()[:2]
|
|
endpoint = CultMesh.parse_rudp_endpoint(f"rudp://{server_host}:{server_port}")
|
|
self.assertEqual(endpoint.host, server_host)
|
|
self.assertEqual(endpoint.port, server_port)
|
|
peer = CultMeshPeerCard(
|
|
peer_id="python-cultmesh-rudp-server",
|
|
verse_id="local",
|
|
endpoints=(endpoint.uri,),
|
|
roles=("schema",),
|
|
authority_lease_id="lease:python-cultmesh-rudp-server",
|
|
)
|
|
peers = CultMesh.create_peer_catalog()
|
|
leases = CultMesh.create_authority_lease_catalog()
|
|
peers.upsert(peer)
|
|
with self.assertRaisesRegex(ValueError, "No authorized RUDP peer"):
|
|
CultMesh.create_rudp_client_for_authorized_peer(
|
|
"python-cultmesh-rudp-client",
|
|
connection_id,
|
|
peers,
|
|
leases,
|
|
"local",
|
|
"schema",
|
|
resend_delay_ms=25,
|
|
max_fragment_bytes=1024,
|
|
max_pending_reliable_packets=16,
|
|
)
|
|
leases.upsert(
|
|
CultMeshAuthorityLease(
|
|
lease_id="lease:python-cultmesh-rudp-server",
|
|
verse_id="local",
|
|
peer_id="python-cultmesh-rudp-server",
|
|
roles=("schema",),
|
|
valid_from=datetime.now(UTC) - timedelta(seconds=1),
|
|
expires_at=datetime.now(UTC) + timedelta(seconds=30),
|
|
issuer_runtime_id="python-authority",
|
|
)
|
|
)
|
|
client = CultMesh.create_rudp_client_for_authorized_peer(
|
|
"python-cultmesh-rudp-client",
|
|
connection_id,
|
|
peers,
|
|
leases,
|
|
"local",
|
|
"schema",
|
|
resend_delay_ms=25,
|
|
max_fragment_bytes=1024,
|
|
max_pending_reliable_packets=16,
|
|
)
|
|
|
|
try:
|
|
client.connect(b"join")
|
|
pump_rudp_handshake(client, server)
|
|
self.assertTrue(client.connected)
|
|
self.assertTrue(server.connected)
|
|
|
|
client.send("schema", b"client-state")
|
|
server_frame = receive_rudp_frame(server)
|
|
self.assertEqual(server_frame.channel_id, "schema")
|
|
self.assertEqual(server_frame.payload, b"client-state")
|
|
self.assertEqual(server.profile["transports"][0]["protocol"], "rudp")
|
|
self.assertEqual(client.profile["transports"][0]["protocol"], "rudp")
|
|
finally:
|
|
client.close()
|
|
server.close()
|
|
|
|
def test_cultmesh_node_applies_foreign_schema_snapshot_as_local_update(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.runtime-policy",
|
|
[
|
|
("schema_version", 0),
|
|
("name", 1),
|
|
("value", 2),
|
|
],
|
|
schema_id="mesh.runtime-policy.current",
|
|
schema_name="mesh.runtime_policy",
|
|
schema_version="mesh.runtime_policy.v1",
|
|
)
|
|
node = create_node(runtime_id="python-node")
|
|
node.register_document(document)
|
|
node.put(document, "policy:1", {
|
|
"schema_version": "mesh.runtime_policy.v1",
|
|
"name": "policy",
|
|
"value": "old",
|
|
})
|
|
observed: list[CultMeshDatabaseChange] = []
|
|
unsubscribe = node.database.watch_record(document, "policy:1", observed.append)
|
|
try:
|
|
applied = node.database.apply_snapshot_response({
|
|
"schemaVersion": "cultnet.snapshot_response_raw.v0",
|
|
"messageId": "foreign-schema-snapshot",
|
|
"documents": [
|
|
{
|
|
"schemaId": "sha256:foreign-mesh-runtime-policy",
|
|
"recordKey": "policy:1",
|
|
"storedAt": "2026-06-25T00:00:00Z",
|
|
"payloadEncoding": "messagepack",
|
|
"payload": document.encode_payload({
|
|
"schema_version": "mesh.runtime_policy.v1",
|
|
"name": "policy",
|
|
"value": "new",
|
|
}),
|
|
},
|
|
],
|
|
})
|
|
finally:
|
|
unsubscribe()
|
|
|
|
local_schema_id = document.catalog_entry().schema_id
|
|
self.assertEqual(applied[0].schema_id, local_schema_id)
|
|
self.assertEqual(observed[0].schema_id, local_schema_id)
|
|
self.assertEqual(observed[0].change_kind, "updated")
|
|
self.assertEqual(observed[0].previous_value["value"], "old")
|
|
self.assertEqual(observed[0].value["value"], "new")
|
|
self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
|
|
|
|
# A record arriving under a compatible id or the schema name is the same record: one id, the local one, so an existing
|
|
# record is updated with its previous value.
|
|
def test_cultmesh_node_reports_an_update_when_a_record_arrives_under_a_compatible_id_or_the_schema_name(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.compatible-policy",
|
|
[("schema_version", 0), ("name", 1), ("value", 2)],
|
|
schema_id="mesh.compatible-policy.current",
|
|
schema_name="mesh.compatible_policy",
|
|
schema_version="mesh.compatible_policy.v1",
|
|
compatible_schema_ids=["mesh.compatible-policy.current", "mesh.compatible-policy.older"],
|
|
)
|
|
for wire_schema_id in ("mesh.compatible-policy.older", "mesh.compatible_policy"):
|
|
node = create_node(runtime_id="python-node")
|
|
node.register_document(document)
|
|
node.put(document, "policy:1", {"schema_version": "mesh.compatible_policy.v1", "name": "policy", "value": "old"})
|
|
observed: list[CultMeshDatabaseChange] = []
|
|
unsubscribe = node.database.watch_record(document, "policy:1", observed.append)
|
|
try:
|
|
applied = node.database.apply_snapshot_response({
|
|
"schemaVersion": "cultnet.snapshot_response_raw.v0",
|
|
"messageId": "compatible-snapshot",
|
|
"documents": [{
|
|
"schemaId": wire_schema_id,
|
|
"recordKey": "policy:1",
|
|
"storedAt": "2026-06-25T00:00:00Z",
|
|
"payloadEncoding": "messagepack",
|
|
"payload": document.encode_payload({
|
|
"schema_version": "mesh.compatible_policy.v1", "name": "policy", "value": "new",
|
|
}),
|
|
}],
|
|
})
|
|
finally:
|
|
unsubscribe()
|
|
|
|
local_schema_id = document.catalog_entry().schema_id
|
|
self.assertEqual(applied[0].schema_id, local_schema_id, wire_schema_id)
|
|
self.assertEqual([change.change_kind for change in observed], ["updated"], wire_schema_id)
|
|
self.assertEqual(observed[0].previous_value["value"], "old", wire_schema_id)
|
|
self.assertEqual(observed[0].value["value"], "new", wire_schema_id)
|
|
|
|
def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None:
|
|
import msgpack # type: ignore
|
|
from cultnet_py import read_frame, write_frame
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.sync_item",
|
|
[
|
|
("name", 0),
|
|
("category", 1),
|
|
("value", 2, 0),
|
|
],
|
|
cls=Item,
|
|
)
|
|
node = create_node(runtime_id="python-node")
|
|
node.register_document(document)
|
|
schema_id = document.catalog_entry().schema_id
|
|
ready = threading.Event()
|
|
server_error: list[BaseException] = []
|
|
port_holder: list[int] = []
|
|
|
|
def serve_requests() -> None:
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server.bind(("127.0.0.1", 0))
|
|
port_holder.append(server.getsockname()[1])
|
|
server.listen(2)
|
|
ready.set()
|
|
for _ in range(2):
|
|
connection, _ = server.accept()
|
|
with connection:
|
|
stream = connection.makefile("rwb")
|
|
request = msgpack.unpackb(read_frame(stream), raw=False)
|
|
if request["schemaVersion"] == "cultnet.snapshot_request.v0":
|
|
response = {
|
|
"schemaVersion": "cultnet.snapshot_response_raw.v0",
|
|
"messageId": request["messageId"],
|
|
"documents": [
|
|
{
|
|
"schemaId": schema_id,
|
|
"recordKey": "item:node",
|
|
"storedAt": "2026-06-13T00:00:00Z",
|
|
"payloadEncoding": "messagepack",
|
|
"payload": document.encode_payload(Item("node", "mesh", 1)),
|
|
}
|
|
],
|
|
}
|
|
elif request["schemaVersion"] == "cultnet.shard_log_request.v0":
|
|
response = {
|
|
"schemaVersion": "cultnet.shard_log_response.v0",
|
|
"messageId": request["messageId"],
|
|
"shardId": request["shardId"],
|
|
"shardEpoch": request["shardEpoch"],
|
|
"resyncRequired": False,
|
|
"entries": [
|
|
{
|
|
"sequence": 1,
|
|
"changeKind": "updated",
|
|
"put": {
|
|
"schemaVersion": "cultnet.document_put_raw.v0",
|
|
"messageId": "put-node",
|
|
"document": {
|
|
"schemaId": schema_id,
|
|
"recordKey": "item:node",
|
|
"storedAt": "2026-06-13T00:00:01Z",
|
|
"payloadEncoding": "messagepack",
|
|
"payload": document.encode_payload(Item("node", "mesh", 9)),
|
|
},
|
|
"shardId": request["shardId"],
|
|
"shardEpoch": request["shardEpoch"],
|
|
},
|
|
}
|
|
],
|
|
}
|
|
else:
|
|
raise AssertionError(f"unexpected request {request['schemaVersion']}")
|
|
write_frame(stream, msgpack.packb(response, use_bin_type=True))
|
|
stream.flush()
|
|
except BaseException as error:
|
|
server_error.append(error)
|
|
ready.set()
|
|
|
|
thread = threading.Thread(target=serve_requests, daemon=True)
|
|
thread.start()
|
|
self.assertTrue(ready.wait(2.0))
|
|
self.assertFalse(server_error)
|
|
|
|
client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
|
|
snapshot_changes = node.sync_snapshot(client, schema_ids=[schema_id])
|
|
log_changes = node.sync_shard_log(client, shard_id="mesh", shard_epoch=1)
|
|
|
|
thread.join(2.0)
|
|
self.assertFalse(server_error)
|
|
self.assertEqual(snapshot_changes[0].record_key, "item:node")
|
|
self.assertEqual(log_changes[0].change_kind, "updated")
|
|
self.assertEqual(node.get_required(document, "item:node").value, 9)
|
|
|
|
def test_cultmesh_node_sync_document_returns_requested_alias_type(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.sync_alias_note",
|
|
[("body", 0)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.sync_alias_note.v1",
|
|
schema_name="mesh.sync_alias_note",
|
|
schema_version="mesh.sync_alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.sync_alias_note.ui",
|
|
[("body", 0)],
|
|
cls=UiNote,
|
|
schema_id="mesh.sync_alias_note.v1",
|
|
schema_name="mesh.sync_alias_note",
|
|
schema_version="mesh.sync_alias_note.v1",
|
|
)
|
|
node = create_node(runtime_id="python-node")
|
|
node.register_document(document)
|
|
requests: list[dict[str, Any]] = []
|
|
|
|
class SnapshotClient:
|
|
def fetch_snapshot_response(
|
|
self,
|
|
*,
|
|
schema_ids: list[str] | None = None,
|
|
record_keys: list[str] | None = None,
|
|
shard_id: str | None = None,
|
|
shard_epoch: int | None = None,
|
|
) -> dict[str, Any]:
|
|
requests.append({
|
|
"schema_ids": schema_ids,
|
|
"record_keys": record_keys,
|
|
"shard_id": shard_id,
|
|
"shard_epoch": shard_epoch,
|
|
})
|
|
return {
|
|
"schemaVersion": "cultnet.snapshot_response_raw.v0",
|
|
"messageId": "sync-document",
|
|
"documents": [
|
|
{
|
|
"schemaId": document.catalog_entry().schema_id,
|
|
"recordKey": "note:remote",
|
|
"storedAt": "2026-06-27T00:00:00Z",
|
|
"payloadEncoding": "messagepack",
|
|
"payload": document.encode_payload(CanonicalNote("synced-alias")),
|
|
}
|
|
],
|
|
}
|
|
|
|
synced = node.sync_document(SnapshotClient(), alias, "note:remote")
|
|
facade_synced = CultMesh.sync_document(node, SnapshotClient(), alias, "note:remote")
|
|
|
|
self.assertIsInstance(synced, UiNote)
|
|
self.assertEqual(synced.body, "synced-alias")
|
|
self.assertIsInstance(facade_synced, UiNote)
|
|
self.assertEqual(facade_synced.body, "synced-alias")
|
|
self.assertEqual(node.get_required(document, "note:remote").body, "synced-alias")
|
|
self.assertIsInstance(node.get_required(alias, "note:remote"), UiNote)
|
|
self.assertEqual(requests, [
|
|
{
|
|
"schema_ids": ["mesh.sync_alias_note.v1"],
|
|
"record_keys": ["note:remote"],
|
|
"shard_id": None,
|
|
"shard_epoch": None,
|
|
},
|
|
{
|
|
"schema_ids": ["mesh.sync_alias_note.v1"],
|
|
"record_keys": ["note:remote"],
|
|
"shard_id": None,
|
|
"shard_epoch": None,
|
|
},
|
|
])
|
|
|
|
def test_cultmesh_node_sync_document_from_publication_returns_requested_alias_type(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.publication_alias_note",
|
|
[("body", 0)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.publication_alias_note.v1",
|
|
schema_name="mesh.publication_alias_note",
|
|
schema_version="mesh.publication_alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.publication_alias_note.ui",
|
|
[("body", 0)],
|
|
cls=UiNote,
|
|
schema_id="mesh.publication_alias_note.v1",
|
|
schema_name="mesh.publication_alias_note",
|
|
schema_version="mesh.publication_alias_note.v1",
|
|
)
|
|
node = create_node(runtime_id="python-publication-node")
|
|
node.register_document(document)
|
|
requests: list[dict[str, Any]] = []
|
|
|
|
class SnapshotClient:
|
|
def fetch_snapshot_response(
|
|
self,
|
|
*,
|
|
schema_ids: list[str] | None = None,
|
|
record_keys: list[str] | None = None,
|
|
shard_id: str | None = None,
|
|
shard_epoch: int | None = None,
|
|
) -> dict[str, Any]:
|
|
requests.append({
|
|
"schema_ids": schema_ids,
|
|
"record_keys": record_keys,
|
|
"shard_id": shard_id,
|
|
"shard_epoch": shard_epoch,
|
|
})
|
|
return {
|
|
"schemaVersion": "cultnet.snapshot_response_raw.v0",
|
|
"messageId": "publication-sync-document",
|
|
"documents": [
|
|
{
|
|
"schemaId": document.catalog_entry().schema_id,
|
|
"recordKey": "note:published-peer",
|
|
"storedAt": "2026-06-27T00:00:00Z",
|
|
"payloadEncoding": "messagepack",
|
|
"payload": document.encode_payload(CanonicalNote("peer-publication-alias")),
|
|
}
|
|
],
|
|
}
|
|
|
|
source = CultMesh.publication_source_from_peer_snapshot(
|
|
SnapshotClient(),
|
|
shard_id="notes",
|
|
shard_epoch=3,
|
|
)
|
|
|
|
synced = node.sync_document_from_publication(source, alias, "note:published-peer")
|
|
facade_synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-peer")
|
|
|
|
self.assertIsInstance(synced, UiNote)
|
|
self.assertEqual(synced.body, "peer-publication-alias")
|
|
self.assertIsInstance(facade_synced, UiNote)
|
|
self.assertEqual(facade_synced.body, "peer-publication-alias")
|
|
self.assertEqual(node.get_required(document, "note:published-peer").body, "peer-publication-alias")
|
|
self.assertEqual(requests, [
|
|
{
|
|
"schema_ids": ["mesh.publication_alias_note.v1"],
|
|
"record_keys": ["note:published-peer"],
|
|
"shard_id": "notes",
|
|
"shard_epoch": 3,
|
|
},
|
|
{
|
|
"schema_ids": ["mesh.publication_alias_note.v1"],
|
|
"record_keys": ["note:published-peer"],
|
|
"shard_id": "notes",
|
|
"shard_epoch": 3,
|
|
},
|
|
])
|
|
|
|
def test_cultmesh_node_sync_document_from_single_file_publication_uses_node_aliases(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.file_publication_alias_note",
|
|
[("body", 0)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.file_publication_alias_note.v1",
|
|
schema_name="mesh.file_publication_alias_note",
|
|
schema_version="mesh.file_publication_alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.file_publication_alias_note.ui",
|
|
[("body", 0)],
|
|
cls=UiNote,
|
|
schema_id="mesh.file_publication_alias_note.v1",
|
|
schema_name="mesh.file_publication_alias_note",
|
|
schema_version="mesh.file_publication_alias_note.v1",
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "publication.ccmp"
|
|
writer = CultCache()
|
|
writer.register_document_type(document)
|
|
writer.add_generic_store(SingleFileMessagePackBackingStore(path))
|
|
writer.put(document, "note:published-file", CanonicalNote("file-publication-alias"))
|
|
|
|
node = create_node(runtime_id="python-file-publication-node")
|
|
node.register_document(document)
|
|
source = CultMesh.publication_source_from_single_file(path)
|
|
|
|
synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-file")
|
|
|
|
self.assertIsInstance(synced, UiNote)
|
|
self.assertEqual(synced.body, "file-publication-alias")
|
|
self.assertEqual(node.get_required(document, "note:published-file").body, "file-publication-alias")
|
|
self.assertIsInstance(node.get_required(alias, "note:published-file"), UiNote)
|
|
|
|
def test_cultmesh_node_sync_documents_from_publication_uses_binding_sources_and_aliases(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.catalog_publication_alias_note",
|
|
[("body", 0)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.catalog_publication_alias_note.v1",
|
|
schema_name="mesh.catalog_publication_alias_note",
|
|
schema_version="mesh.catalog_publication_alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.catalog_publication_alias_note.ui",
|
|
[("body", 0)],
|
|
cls=UiNote,
|
|
schema_id="mesh.catalog_publication_alias_note.v1",
|
|
schema_name="mesh.catalog_publication_alias_note",
|
|
schema_version="mesh.catalog_publication_alias_note.v1",
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
first_path = Path(tmp) / "first.ccmp"
|
|
second_path = Path(tmp) / "second.ccmp"
|
|
first = CultCache()
|
|
first.register_document_type(document)
|
|
first.add_generic_store(SingleFileMessagePackBackingStore(first_path))
|
|
first.put(document, "note:first", CanonicalNote("first catalog publication"))
|
|
second = CultCache()
|
|
second.register_document_type(document)
|
|
second.add_generic_store(SingleFileMessagePackBackingStore(second_path))
|
|
second.put(document, "note:second", CanonicalNote("second catalog publication"))
|
|
|
|
node = create_node(runtime_id="python-catalog-publication-node")
|
|
node.register_document(document)
|
|
source = CultMesh.publication_source_from_single_file(first_path)
|
|
bindings = [
|
|
CultMesh.publication_document(alias, "note:first"),
|
|
CultMesh.publication_document(
|
|
alias,
|
|
"note:second",
|
|
source=CultMesh.publication_source_from_single_file(second_path),
|
|
),
|
|
]
|
|
|
|
synced = node.sync_documents_from_publication(source, bindings)
|
|
facade_synced = CultMesh.sync_documents_from_publication(node, source, bindings)
|
|
|
|
self.assertEqual([value.body for value in synced], [
|
|
"first catalog publication",
|
|
"second catalog publication",
|
|
])
|
|
self.assertTrue(all(isinstance(value, UiNote) for value in synced))
|
|
self.assertEqual([value.body for value in facade_synced], [
|
|
"first catalog publication",
|
|
"second catalog publication",
|
|
])
|
|
self.assertEqual(node.get_required(document, "note:first").body, "first catalog publication")
|
|
self.assertEqual(node.get_required(document, "note:second").body, "second catalog publication")
|
|
self.assertIsInstance(node.get_required(alias, "note:second"), UiNote)
|
|
|
|
def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.emit_item",
|
|
[
|
|
("name", 0),
|
|
("category", 1),
|
|
("value", 2, 0),
|
|
],
|
|
cls=Item,
|
|
)
|
|
node = create_node(runtime_id="python-node")
|
|
node.register_document(document)
|
|
|
|
put = node.put_raw_message(
|
|
document,
|
|
"item:emit",
|
|
Item("wand", "gear", 4),
|
|
message_id="put-emit",
|
|
shard_id="mesh",
|
|
shard_epoch=1,
|
|
).to_wire()
|
|
self.assertEqual(node.get_required(document, "item:emit").name, "wand")
|
|
self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0")
|
|
self.assertEqual(put["messageId"], "put-emit")
|
|
self.assertEqual(put["document"]["schemaId"], document.catalog_entry().schema_id)
|
|
self.assertEqual(put["document"]["recordKey"], "item:emit")
|
|
self.assertEqual(put["document"]["sourceRuntimeId"], "python-node")
|
|
self.assertEqual(put["shardId"], "mesh")
|
|
self.assertEqual(put["shardEpoch"], 1)
|
|
|
|
delete = node.delete_raw_message(
|
|
document,
|
|
"item:emit",
|
|
message_id="delete-emit",
|
|
shard_id="mesh",
|
|
shard_epoch=1,
|
|
).to_wire()
|
|
self.assertIsNone(node.get(document, "item:emit"))
|
|
self.assertEqual(delete["schemaVersion"], "cultnet.document_delete.v0")
|
|
self.assertEqual(delete["messageId"], "delete-emit")
|
|
self.assertEqual(delete["schemaId"], document.catalog_entry().schema_id)
|
|
self.assertEqual(delete["recordKey"], "item:emit")
|
|
self.assertEqual(delete["shardId"], "mesh")
|
|
self.assertEqual(delete["shardEpoch"], 1)
|
|
|
|
def test_cultmesh_node_resolves_same_schema_aliases_for_local_state(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.alias_note",
|
|
[("body", 0)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.alias_note.v1",
|
|
schema_name="mesh.alias_note",
|
|
schema_version="mesh.alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.alias_note.ui",
|
|
[("body", 0)],
|
|
cls=UiNote,
|
|
schema_id="mesh.alias_note.v1",
|
|
schema_name="mesh.alias_note",
|
|
schema_version="mesh.alias_note.v1",
|
|
)
|
|
node = create_node(runtime_id="python-node")
|
|
node.register_document(document)
|
|
seen: list[CultMeshDatabaseChange] = []
|
|
all_seen: list[CultMeshDatabaseChange] = []
|
|
node.database.watch(all_seen.append, document=alias)
|
|
node.database.watch_record(alias, "note:alias", seen.append)
|
|
|
|
node.put(alias, "note:alias", UiNote("from-alias-put"))
|
|
self.assertEqual(node.get_required(document, "note:alias").body, "from-alias-put")
|
|
alias_value = node.get_required(alias, "note:alias")
|
|
self.assertIsInstance(alias_value, UiNote)
|
|
self.assertEqual(alias_value.body, "from-alias-put")
|
|
alias_collection = node.get_all(alias)
|
|
self.assertEqual(len(alias_collection), 1)
|
|
self.assertIsInstance(alias_collection[0], UiNote)
|
|
self.assertEqual(alias_collection[0].body, "from-alias-put")
|
|
self.assertEqual(seen[0].document.type, alias.type)
|
|
self.assertEqual(seen[0].schema_id, "mesh.alias_note.v1")
|
|
self.assertIsInstance(seen[0].value, UiNote)
|
|
self.assertEqual(seen[0].value.body, "from-alias-put")
|
|
self.assertEqual(all_seen[0].document.type, alias.type)
|
|
self.assertIsInstance(all_seen[0].value, UiNote)
|
|
self.assertEqual(all_seen[0].value.body, "from-alias-put")
|
|
|
|
node.put(document, "note:alias", CanonicalNote("from-canonical-put"))
|
|
self.assertEqual(seen[1].document.type, alias.type)
|
|
self.assertIsInstance(seen[1].value, UiNote)
|
|
self.assertIsInstance(seen[1].previous_value, UiNote)
|
|
self.assertEqual(seen[1].value.body, "from-canonical-put")
|
|
self.assertEqual(seen[1].previous_value.body, "from-alias-put")
|
|
|
|
put = node.put_raw_message(
|
|
alias,
|
|
"note:alias",
|
|
UiNote("from-alias-raw-put"),
|
|
message_id="alias-put",
|
|
).to_wire()
|
|
self.assertEqual(put["document"]["schemaId"], "mesh.alias_note.v1")
|
|
self.assertEqual(put["document"]["recordKey"], "note:alias")
|
|
self.assertEqual(node.get_required(alias, "note:alias").body, "from-alias-raw-put")
|
|
|
|
delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire()
|
|
self.assertEqual(delete["schemaId"], "mesh.alias_note.v1")
|
|
self.assertIsNone(node.get(alias, "note:alias"))
|
|
self.assertEqual(node.get_all(alias), [])
|
|
|
|
def test_cultmesh_reactive_document_coalesces_explicit_alias_updates(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
revision: int
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
revision: int
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.reactive_alias_note",
|
|
[("body", 0), ("revision", 1)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.reactive_alias_note.v1",
|
|
schema_name="mesh.reactive_alias_note",
|
|
schema_version="mesh.reactive_alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.reactive_alias_note.ui",
|
|
[("body", 0), ("revision", 1)],
|
|
cls=UiNote,
|
|
schema_id="mesh.reactive_alias_note.v1",
|
|
schema_name="mesh.reactive_alias_note",
|
|
schema_version="mesh.reactive_alias_note.v1",
|
|
)
|
|
node = create_node(runtime_id="python-reactive")
|
|
node.register_document(document)
|
|
node.put(document, "note:reactive", CanonicalNote("initial", 1))
|
|
seen: list[CultMeshDatabaseChange] = []
|
|
node.database.watch_record(alias, "note:reactive", seen.append)
|
|
|
|
reactive = node.authoritative_writer(alias, "note:reactive").reactive(
|
|
CultMeshReactiveDocumentOptions(flush_delay_seconds=0.02)
|
|
)
|
|
try:
|
|
self.assertIsInstance(reactive.snapshot, UiNote)
|
|
reactive.update(lambda note: setattr(note, "body", "first-local-edit"))
|
|
reactive.update(lambda note: setattr(note, "body", "second-local-edit"))
|
|
reactive.update(lambda note: setattr(note, "revision", 2))
|
|
|
|
self._wait_until(lambda: node.get_required(alias, "note:reactive").body == "second-local-edit")
|
|
|
|
self.assertFalse(reactive.is_dirty)
|
|
self.assertEqual(node.get_required(document, "note:reactive").revision, 2)
|
|
self.assertEqual(seen[-1].value.body, "second-local-edit")
|
|
finally:
|
|
reactive.dispose()
|
|
|
|
def test_cultmesh_reactive_document_tracks_canonical_reconciliation_delta(self) -> None:
|
|
@dataclass
|
|
class Note:
|
|
body: str
|
|
revision: int
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.reactive_reconcile_note",
|
|
[("body", 0), ("revision", 1)],
|
|
cls=Note,
|
|
schema_id="mesh.reactive_reconcile_note.v1",
|
|
schema_name="mesh.reactive_reconcile_note",
|
|
schema_version="mesh.reactive_reconcile_note.v1",
|
|
)
|
|
node = create_node(runtime_id="python-reactive-reconcile")
|
|
node.register_document(document)
|
|
node.put(document, "note:reconcile", Note("initial", 1))
|
|
|
|
reactive = CultMesh.authoritative_writer(
|
|
node,
|
|
document,
|
|
"note:reconcile",
|
|
).reactive(CultMeshReactiveDocumentOptions(flush_delay_seconds=60))
|
|
try:
|
|
reactive.update(lambda note: setattr(note, "body", "local-prediction"))
|
|
reactive.update(lambda note: setattr(note, "revision", 2))
|
|
|
|
node.put(document, "note:reconcile", Note("authoritative", 7))
|
|
|
|
self.assertEqual(reactive.snapshot.body, "local-prediction")
|
|
self.assertIsNotNone(reactive.reconciliation)
|
|
self.assertEqual(reactive.reconciliation.canonical.body, "authoritative")
|
|
self.assertEqual(reactive.reconciliation.predicted.body, "local-prediction")
|
|
self.assertEqual(reactive.reconciliation.delta["body"], "local-prediction")
|
|
self.assertEqual(reactive.reconciliation.delta["revision"], -5)
|
|
|
|
reactive.flush()
|
|
self.assertEqual(node.get_required(document, "note:reconcile").body, "local-prediction")
|
|
self.assertIsNone(reactive.reconciliation)
|
|
finally:
|
|
reactive.dispose()
|
|
|
|
def test_cultmesh_reactive_document_is_idle_until_explicit_nested_update(self) -> None:
|
|
document = define_document_type(
|
|
"mesh.nested_loadout",
|
|
encode=lambda value: value,
|
|
decode=lambda value: value,
|
|
)
|
|
node = create_node(runtime_id="python-reactive-nested")
|
|
node.register_document(document)
|
|
node.put(
|
|
document,
|
|
"ship:nested",
|
|
{"loadout": {"weapon": {"tuning": {"damage": 10}}}},
|
|
)
|
|
|
|
with patch("cultmesh_py.node.threading.Timer") as timer:
|
|
reactive = node.authoritative_writer(document, "ship:nested").reactive()
|
|
timer.assert_not_called()
|
|
|
|
try:
|
|
borrowed = reactive.snapshot
|
|
borrowed["loadout"]["weapon"]["tuning"]["damage"] = 99
|
|
self.assertEqual(
|
|
node.get_required(document, "ship:nested")["loadout"]["weapon"]["tuning"]["damage"],
|
|
10,
|
|
)
|
|
|
|
reactive.update(
|
|
lambda draft: draft["loadout"]["weapon"]["tuning"].__setitem__("damage", 12)
|
|
)
|
|
reactive.flush()
|
|
|
|
self.assertEqual(reactive.snapshot["loadout"]["weapon"]["tuning"]["damage"], 12)
|
|
self.assertEqual(
|
|
node.get_required(document, "ship:nested")["loadout"]["weapon"]["tuning"]["damage"],
|
|
12,
|
|
)
|
|
finally:
|
|
reactive.dispose()
|
|
|
|
def test_cultmesh_reactive_scheduling_scales_with_changed_documents_only(self) -> None:
|
|
document = define_document_type(
|
|
"mesh.reactive_scale_note",
|
|
encode=lambda value: value,
|
|
decode=lambda value: value,
|
|
)
|
|
|
|
for document_count in (1, 100, 1000):
|
|
node = create_node(runtime_id=f"python-reactive-scale-{document_count}")
|
|
node.register_document(document)
|
|
for index in range(document_count):
|
|
node.put(document, f"note:{index}", {"body": "initial", "revision": 1})
|
|
|
|
scheduled_timers: list[Any] = []
|
|
|
|
class FakeScheduledCall:
|
|
def __init__(self, delay_seconds: float, callback: Callable[[], None]) -> None:
|
|
self.delay_seconds = delay_seconds
|
|
self.callback = callback
|
|
self.cancelled = False
|
|
|
|
def cancel(self) -> None:
|
|
self.cancelled = True
|
|
|
|
class FakeScheduler:
|
|
def schedule(
|
|
self,
|
|
delay_seconds: float,
|
|
callback: Callable[[], None],
|
|
) -> FakeScheduledCall:
|
|
call = FakeScheduledCall(delay_seconds, callback)
|
|
scheduled_timers.append(call)
|
|
return call
|
|
|
|
reactive_documents: list[Any] = []
|
|
with patch("cultmesh_py.node._REACTIVE_SCHEDULER", FakeScheduler()):
|
|
try:
|
|
for index in range(document_count):
|
|
reactive_documents.append(
|
|
node.authoritative_writer(document, f"note:{index}").reactive()
|
|
)
|
|
|
|
self.assertEqual(
|
|
len(scheduled_timers),
|
|
0,
|
|
f"{document_count} idle documents must schedule no timers",
|
|
)
|
|
|
|
changed_document_count = max(1, document_count // 100)
|
|
for index in range(changed_document_count):
|
|
reactive_documents[index].update(
|
|
lambda draft, changed=index: draft.update(
|
|
body=f"changed-{changed}",
|
|
revision=2,
|
|
)
|
|
)
|
|
|
|
self.assertEqual(len(scheduled_timers), changed_document_count)
|
|
self.assertEqual(
|
|
sum(reactive.is_dirty for reactive in reactive_documents),
|
|
changed_document_count,
|
|
)
|
|
finally:
|
|
for reactive in reactive_documents:
|
|
reactive.dispose()
|
|
|
|
self.assertTrue(all(timer.cancelled for timer in scheduled_timers))
|
|
|
|
def test_cultmesh_reactive_documents_share_one_lazy_scheduler_thread(self) -> None:
|
|
document = define_document_type(
|
|
"mesh.reactive_shared_scheduler_note",
|
|
encode=lambda value: value,
|
|
decode=lambda value: value,
|
|
)
|
|
node = create_node(runtime_id="python-reactive-shared-scheduler")
|
|
node.register_document(document)
|
|
reactive_documents: list[Any] = []
|
|
try:
|
|
for index in range(100):
|
|
node.put(document, f"note:{index}", {"body": "initial", "revision": 1})
|
|
reactive_documents.append(
|
|
node.authoritative_writer(document, f"note:{index}").reactive(
|
|
CultMeshReactiveDocumentOptions(flush_delay_seconds=60.0)
|
|
)
|
|
)
|
|
|
|
before = sum(
|
|
thread.name == "cultmesh-reactive-scheduler"
|
|
for thread in threading.enumerate()
|
|
)
|
|
for index, reactive in enumerate(reactive_documents):
|
|
reactive.update(
|
|
lambda draft, changed=index: draft.update(
|
|
body=f"changed-{changed}",
|
|
revision=2,
|
|
)
|
|
)
|
|
after = sum(
|
|
thread.name == "cultmesh-reactive-scheduler"
|
|
for thread in threading.enumerate()
|
|
)
|
|
|
|
self.assertLessEqual(before, 1)
|
|
self.assertEqual(after, 1)
|
|
finally:
|
|
for reactive in reactive_documents:
|
|
reactive.dispose()
|
|
|
|
def test_cultmesh_observed_document_cannot_publish_borrowed_mutation(self) -> None:
|
|
document = define_document_type(
|
|
"mesh.observed_note",
|
|
encode=lambda value: value,
|
|
decode=lambda value: value,
|
|
)
|
|
node = create_node(runtime_id="python-observed")
|
|
node.register_document(document)
|
|
node.put(document, "note:observed", {"body": "initial"})
|
|
|
|
observed = node.observe_document(document, "note:observed")
|
|
try:
|
|
observed.current["body"] = "borrowed-local-edit"
|
|
self.assertEqual(node.get_required(document, "note:observed")["body"], "initial")
|
|
|
|
node.put(document, "note:observed", {"body": "canonical"})
|
|
self.assertEqual(observed.current["body"], "canonical")
|
|
finally:
|
|
observed.dispose()
|
|
|
|
def test_cultmesh_node_uses_cultcache_store(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
document = define_database_entry_type("mesh.note", [("body", 0)])
|
|
store_path = Path(tmp) / "mesh.cc"
|
|
node = create_node(store_path, runtime_id="mesh-test")
|
|
self.assertIsInstance(node.database, CultMeshDatabase)
|
|
node.database.register_document(document)
|
|
node.database.put(document, "note:1", {"body": "hello"})
|
|
self.assertEqual(node.get_required(document, "note:1")["body"], "hello")
|
|
|
|
reopened = create_node(store_path, runtime_id="mesh-test")
|
|
reopened.database.register_document(document)
|
|
reopened.database.pull()
|
|
self.assertEqual(reopened.database.get(document, "note:1")["body"], "hello")
|
|
|
|
def test_cultmesh_node_options_attach_default_durable_shard_log_store(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
document = define_database_entry_type(
|
|
"mesh.durable_option_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.durable_option_note.v1",
|
|
)
|
|
cache_path = Path(tmp) / "world.cc"
|
|
node = CultMesh.create_node(
|
|
cache_path,
|
|
runtime_id="durable-option-primary",
|
|
options=CultMeshNodeOptions(enable_durable_shard_logs=True),
|
|
)
|
|
node.database.register_document(document)
|
|
node.database.put_raw_message(document, "note:1", {"body": "stored"}, shard_id="notes", shard_epoch=5)
|
|
|
|
log_path = Path(tmp) / "world.cultmesh" / "shard-logs"
|
|
self.assertTrue(log_path.exists())
|
|
|
|
reopened = CultMesh.start_node(
|
|
cache_path,
|
|
runtime_id="durable-option-primary",
|
|
enable_durable_shard_logs=True,
|
|
)
|
|
reopened.database.register_document(document)
|
|
response = reopened.database.build_shard_log_response(shard_id="notes", shard_epoch=5)
|
|
|
|
self.assertEqual(reopened.database.shard_ids(), ["notes"])
|
|
self.assertEqual([entry.sequence for entry in response.entries], [1])
|
|
self.assertEqual(response.entries[0].raw_document.record_key, "note:1")
|
|
|
|
def test_cultmesh_database_watchers_observe_local_changes_and_unsubscribe(self) -> None:
|
|
document = define_database_entry_type("mesh.watch", [("body", 0)])
|
|
node = CultMesh.create_node(runtime_id="mesh-watch")
|
|
node.database.register_document(document)
|
|
changes: list[CultMeshDatabaseChange] = []
|
|
all_changes: list[CultMeshDatabaseChange] = []
|
|
|
|
unsubscribe_record = node.database.watch_record(document, "note:1", changes.append)
|
|
unsubscribe_all = node.database.watch(all_changes.append, document=document)
|
|
|
|
node.database.put(document, "note:1", {"body": "hello"})
|
|
node.database.put(document, "note:2", {"body": "skip"})
|
|
node.database.put(document, "note:1", {"body": "updated"})
|
|
node.database.delete(document, "note:1")
|
|
unsubscribe_record()
|
|
node.database.put(document, "note:1", {"body": "after-unsubscribe"})
|
|
unsubscribe_all()
|
|
|
|
self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"])
|
|
self.assertEqual([change.record_key for change in changes], ["note:1", "note:1", "note:1"])
|
|
self.assertEqual(changes[1].previous_value, {"body": "hello"})
|
|
self.assertIsNone(changes[2].value)
|
|
self.assertEqual([change.record_key for change in all_changes], ["note:1", "note:2", "note:1", "note:1", "note:1"])
|
|
|
|
def test_cultmesh_database_register_document_is_idempotent(self) -> None:
|
|
document = define_database_entry_type("mesh.idempotent", [("body", 0)])
|
|
conflicting = define_database_entry_type("mesh.idempotent", [("title", 0)])
|
|
node = CultMesh.create_node(runtime_id="mesh-idempotent")
|
|
|
|
node.database.register_document(document)
|
|
node.database.register_document(document)
|
|
node.database.put(document, "note:1", {"body": "still registered once"})
|
|
|
|
self.assertEqual(node.database.get_required(document, "note:1")["body"], "still registered once")
|
|
self.assertEqual([registered.type for registered in node.documents], ["mesh.idempotent"])
|
|
with self.assertRaises(ValueError):
|
|
node.database.register_document(conflicting)
|
|
|
|
def test_cultmesh_database_global_document_facade_uses_singleton_key_and_watchers(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.global_settings",
|
|
[("theme", 0)],
|
|
global_document=True,
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-global")
|
|
node.database.register_document(document)
|
|
changes: list[CultMeshDatabaseChange] = []
|
|
|
|
node.database.watch_global(document, changes.append)
|
|
node.database.put_global(document, {"theme": "ash"})
|
|
node.put_global(document, {"theme": "ember"})
|
|
node.delete_global(document)
|
|
|
|
self.assertIsNone(node.database.get_global(document))
|
|
self.assertEqual([change.record_key for change in changes], [node.cache.GLOBAL_KEY] * 3)
|
|
self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"])
|
|
self.assertEqual(changes[1].previous_value, {"theme": "ash"})
|
|
self.assertEqual(changes[2].previous_value, {"theme": "ember"})
|
|
with self.assertRaises(Exception):
|
|
node.database.put(document, "settings:wrong", {"theme": "bad"})
|
|
|
|
def test_cultmesh_database_creates_raw_snapshot_response_from_envelopes(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.snapshot_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.snapshot_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="mesh-snapshot-source")
|
|
source.database.register_document(document)
|
|
source.database.put(document, "note:1", {"body": "include"})
|
|
source.database.put(document, "note:2", {"body": "skip"})
|
|
|
|
typed_response = source.database.build_snapshot_response(
|
|
message_id="snapshot-1",
|
|
schema_ids=["mesh.snapshot_note.v1"],
|
|
record_keys=["note:1"],
|
|
shard_id="notes",
|
|
shard_epoch=4,
|
|
shard_log_sequence=7,
|
|
)
|
|
response = source.database.create_snapshot_response(
|
|
message_id="snapshot-1",
|
|
schema_ids=["mesh.snapshot_note.v1"],
|
|
record_keys=["note:1"],
|
|
shard_id="notes",
|
|
shard_epoch=4,
|
|
shard_log_sequence=7,
|
|
)
|
|
|
|
self.assertIsInstance(typed_response, CultNetRawSnapshotResponse)
|
|
self.assertEqual(typed_response.message_id, "snapshot-1")
|
|
self.assertEqual(typed_response.documents[0].record_key, "note:1")
|
|
self.assertEqual(typed_response.to_wire(), response)
|
|
self.assertEqual(source.build_snapshot_response(schema_ids=["mesh.snapshot_note.v1"]).documents[0].record_key, "note:1")
|
|
self.assertEqual(response["schemaVersion"], "cultnet.snapshot_response_raw.v0")
|
|
self.assertEqual(response["messageId"], "snapshot-1")
|
|
self.assertEqual(response["shardId"], "notes")
|
|
self.assertEqual(response["shardEpoch"], 4)
|
|
self.assertEqual(response["shardLogSequence"], 7)
|
|
self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:1"])
|
|
self.assertEqual(response["documents"][0]["schemaId"], "mesh.snapshot_note.v1")
|
|
self.assertEqual(response["documents"][0]["payloadEncoding"], "messagepack")
|
|
|
|
target = CultMesh.create_node(runtime_id="mesh-snapshot-target")
|
|
target.database.register_document(document)
|
|
applied = target.database.apply_snapshot_response(response)
|
|
self.assertEqual([(record.schema_id, record.record_key) for record in applied], [("mesh.snapshot_note.v1", "note:1")])
|
|
self.assertEqual(target.database.get_required(document, "note:1"), {"body": "include"})
|
|
self.assertIsNone(target.database.get(document, "note:2"))
|
|
|
|
typed_target = CultMesh.create_node(runtime_id="mesh-snapshot-typed-target")
|
|
typed_target.database.register_document(document)
|
|
typed_applied = typed_target.database.apply_snapshot_response(typed_response)
|
|
self.assertEqual([(record.schema_id, record.record_key) for record in typed_applied], [("mesh.snapshot_note.v1", "note:1")])
|
|
self.assertEqual(typed_target.database.get_required(document, "note:1"), {"body": "include"})
|
|
|
|
def test_cultmesh_database_filters_snapshot_by_logged_shard_membership(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.sharded_snapshot_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.sharded_snapshot_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="mesh-sharded-snapshot-source")
|
|
source.database.register_document(document)
|
|
source.database.put_raw_message(document, "note:notes", {"body": "include"}, shard_id="notes", shard_epoch=2)
|
|
source.database.put_raw_message(document, "note:other", {"body": "skip"}, shard_id="other", shard_epoch=1)
|
|
|
|
response = source.database.build_snapshot_response(
|
|
schema_ids=["mesh.sharded_snapshot_note.v1"],
|
|
shard_id="notes",
|
|
shard_epoch=2,
|
|
)
|
|
|
|
self.assertEqual(response.shard_id, "notes")
|
|
self.assertEqual(response.shard_epoch, 2)
|
|
self.assertEqual([record.record_key for record in response.documents], ["note:notes"])
|
|
|
|
def test_cultmesh_database_creates_shard_log_response_from_raw_mutations(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.log_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.log_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="mesh-log-source")
|
|
source.database.register_document(document)
|
|
source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="notes", shard_epoch=3)
|
|
source.database.put_raw_message(document, "note:1", {"body": "second"}, shard_id="notes", shard_epoch=3)
|
|
source.database.put_raw_message(document, "note:2", {"body": "other"}, shard_id="other", shard_epoch=1)
|
|
source.database.delete_raw_message(document, "note:1", shard_id="notes", shard_epoch=3)
|
|
|
|
self.assertEqual(source.database.shard_ids(), ["notes", "other"])
|
|
self.assertEqual(source.database.shard_schema_ids("notes"), ["mesh.log_note.v1"])
|
|
|
|
typed_response = source.database.build_shard_log_response(
|
|
message_id="log-1",
|
|
shard_id="notes",
|
|
shard_epoch=3,
|
|
after_sequence=1,
|
|
limit=2,
|
|
)
|
|
response = source.database.create_shard_log_response(
|
|
message_id="log-1",
|
|
shard_id="notes",
|
|
shard_epoch=3,
|
|
after_sequence=1,
|
|
limit=2,
|
|
)
|
|
|
|
self.assertIsInstance(typed_response, CultNetShardLogResponse)
|
|
self.assertEqual(typed_response.message_id, "log-1")
|
|
self.assertEqual([entry.sequence for entry in typed_response.entries], [2, 3])
|
|
self.assertEqual(typed_response.to_wire(), response)
|
|
self.assertEqual([entry.sequence for entry in source.build_shard_log_response(shard_id="notes").entries], [1, 2, 3])
|
|
self.assertEqual(response["schemaVersion"], "cultnet.shard_log_response.v0")
|
|
self.assertEqual(response["messageId"], "log-1")
|
|
self.assertEqual(response["shardId"], "notes")
|
|
self.assertEqual(response["shardEpoch"], 3)
|
|
self.assertFalse(response["resyncRequired"])
|
|
self.assertEqual([entry["sequence"] for entry in response["entries"]], [2, 3])
|
|
self.assertEqual([entry["changeKind"] for entry in response["entries"]], ["updated", "removed"])
|
|
self.assertEqual(response["entries"][0]["put"]["document"]["recordKey"], "note:1")
|
|
self.assertEqual(response["entries"][1]["delete"]["recordKey"], "note:1")
|
|
empty = source.database.create_shard_log_response(shard_id="missing")
|
|
self.assertEqual(empty["shardEpoch"], 0)
|
|
self.assertEqual(empty["entries"], [])
|
|
self.assertTrue(empty["resyncRequired"])
|
|
self.assertEqual(empty["reason"], "unknown_shard")
|
|
stale = source.database.create_shard_log_response(shard_id="notes", shard_epoch=2)
|
|
self.assertEqual(stale["shardEpoch"], 3)
|
|
self.assertEqual(stale["entries"], [])
|
|
self.assertTrue(stale["resyncRequired"])
|
|
self.assertEqual(stale["reason"], "stale_epoch")
|
|
with self.assertRaises(ValueError):
|
|
source.database.create_shard_log_response(shard_id="notes", after_sequence=-1)
|
|
with self.assertRaises(ValueError):
|
|
source.database.create_shard_log_response(shard_id="notes", limit=-1)
|
|
|
|
target = CultMesh.create_node(runtime_id="mesh-log-target")
|
|
target.database.register_document(document)
|
|
target.database.put(document, "note:1", {"body": "first"})
|
|
applied = target.database.apply_shard_log_response(response)
|
|
self.assertEqual([record.change_kind for record in applied], ["updated", "removed"])
|
|
self.assertIsNone(target.database.get(document, "note:1"))
|
|
self.assertIsNone(target.database.get(document, "note:2"))
|
|
|
|
typed_target = CultMesh.create_node(runtime_id="mesh-log-typed-target")
|
|
typed_target.database.register_document(document)
|
|
typed_target.database.put(document, "note:1", {"body": "first"})
|
|
typed_applied = typed_target.database.apply_shard_log_response(typed_response)
|
|
self.assertEqual([record.change_kind for record in typed_applied], ["updated", "removed"])
|
|
self.assertIsNone(typed_target.database.get(document, "note:1"))
|
|
|
|
def test_cultnet_file_shard_mutation_log_store_persists_authoritative_entries(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.durable_log_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.durable_log_note.v1",
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
store: CultNetShardMutationLogStore = CultNetFileShardMutationLogStore(Path(tmp) / "logs")
|
|
source = CultMesh.create_node(runtime_id="durable-primary")
|
|
source.database.register_document(document)
|
|
source.database.use_shard_mutation_log_store(store)
|
|
source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="durable", shard_epoch=4)
|
|
source.database.put_raw_message(document, "note:2", {"body": "second"}, shard_id="durable", shard_epoch=4)
|
|
|
|
reopened = CultMesh.create_node(runtime_id="durable-primary")
|
|
reopened.database.register_document(document)
|
|
reopened.database.use_shard_mutation_log_store(CultNetFileShardMutationLogStore(Path(tmp) / "logs"))
|
|
response = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4)
|
|
self.assertEqual(reopened.database.shard_ids(), ["durable"])
|
|
self.assertEqual(reopened.database.shard_schema_ids("durable"), ["mesh.durable_log_note.v1"])
|
|
self.assertEqual([entry.sequence for entry in response.entries], [1, 2])
|
|
self.assertEqual(response.entries[0].raw_document.record_key, "note:1")
|
|
|
|
store.compact_through("durable", 1)
|
|
compacted = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=0)
|
|
retained = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=1)
|
|
self.assertTrue(compacted.resync_required)
|
|
self.assertEqual(compacted.reason, "compacted_history")
|
|
self.assertEqual(compacted.compacted_through, 1)
|
|
self.assertEqual([entry.sequence for entry in retained.entries], [2])
|
|
|
|
def test_cultnet_shard_replicator_pulls_logs_and_resyncs_from_snapshot(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.replica_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.replica_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="primary")
|
|
source.database.register_document(document)
|
|
source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="notes", shard_epoch=3)
|
|
source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="notes", shard_epoch=3)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
cursor_path = Path(tmp) / "replica-cursors.msgpack"
|
|
server = CultMesh.serve_node(source)
|
|
try:
|
|
shard = CultNetShardDescriptor(
|
|
shard_id="notes",
|
|
owner_runtime_id="primary",
|
|
epoch=3,
|
|
is_primary=False,
|
|
schema_ids=("mesh.replica_note.v1",),
|
|
primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
)
|
|
target = CultMesh.create_node(runtime_id="replica")
|
|
target.database.register_document(document)
|
|
cursor_store = CultNetFileShardReplicaCursorStore(cursor_path)
|
|
replicator = CultNetShardReplicator(
|
|
target.database,
|
|
CultNetShardReplicatorOptions(
|
|
fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0),
|
|
snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0),
|
|
cursor_store=cursor_store,
|
|
batch_size=16,
|
|
),
|
|
)
|
|
|
|
first_sequence = replicator.pull_once(shard)
|
|
restarted_replicator = CultNetShardReplicator(
|
|
target.database,
|
|
CultNetShardReplicatorOptions(
|
|
fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0),
|
|
snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0),
|
|
cursor_store=CultNetFileShardReplicaCursorStore(cursor_path),
|
|
batch_size=16,
|
|
),
|
|
)
|
|
second_sequence = restarted_replicator.pull_once(shard)
|
|
|
|
self.assertEqual(first_sequence, 2)
|
|
self.assertEqual(second_sequence, 2)
|
|
self.assertEqual(target.get_required(document, "note:1")["body"], "one")
|
|
self.assertEqual(target.get_required(document, "note:2")["body"], "two")
|
|
cursor = CultNetFileShardReplicaCursorStore(cursor_path).read("notes")
|
|
self.assertIsNotNone(cursor)
|
|
assert cursor is not None
|
|
self.assertEqual(cursor.last_applied_sequence, 2)
|
|
self.assertEqual(cursor.shard_epoch, 3)
|
|
finally:
|
|
server.stop()
|
|
|
|
resync_target = CultMesh.create_node(runtime_id="resync-replica")
|
|
resync_target.database.register_document(document)
|
|
snapshot = source.database.build_snapshot_response(shard_id="notes", shard_epoch=3, shard_log_sequence=2)
|
|
|
|
class CompactingFetcher:
|
|
def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse:
|
|
return CultNetShardLogResponse(
|
|
message_id="compact",
|
|
shard_id=shard.shard_id,
|
|
shard_epoch=shard.epoch,
|
|
entries=(),
|
|
resync_required=True,
|
|
reason="compacted_history",
|
|
compacted_through=after_sequence,
|
|
)
|
|
|
|
class SnapshotFetcher:
|
|
def fetch(self, shard: CultNetShardDescriptor) -> CultNetRawSnapshotResponse:
|
|
return snapshot
|
|
|
|
resync_replicator = CultNetShardReplicator(
|
|
resync_target.database,
|
|
CultNetShardReplicatorOptions(
|
|
fetcher=CompactingFetcher(),
|
|
snapshot_fetcher=SnapshotFetcher(),
|
|
cursor_store=CultNetInMemoryShardReplicaCursorStore(),
|
|
),
|
|
)
|
|
resync_sequence = resync_replicator.pull_once(CultNetShardDescriptor(
|
|
shard_id="notes",
|
|
owner_runtime_id="primary",
|
|
epoch=3,
|
|
is_primary=False,
|
|
schema_ids=("mesh.replica_note.v1",),
|
|
primary_endpoints=("cultnet://primary:3075",),
|
|
))
|
|
|
|
self.assertEqual(resync_sequence, 2)
|
|
self.assertEqual(resync_target.get_required(document, "note:1")["body"], "one")
|
|
|
|
def test_cultnet_shard_replicator_background_loop_polls_non_primary_shards(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.background_replica_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.background_replica_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="background-primary")
|
|
source.database.register_document(document)
|
|
target = CultMesh.create_node(runtime_id="background-replica")
|
|
target.database.register_document(document)
|
|
cursor_store = CultNetInMemoryShardReplicaCursorStore()
|
|
errors: list[Exception] = []
|
|
|
|
class FlakySourceFetcher:
|
|
def __init__(self) -> None:
|
|
self.calls = 0
|
|
|
|
def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse:
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
raise RuntimeError("transient fetch failure")
|
|
return source.database.build_shard_log_response(
|
|
shard_id=shard.shard_id,
|
|
shard_epoch=shard.epoch,
|
|
after_sequence=after_sequence,
|
|
limit=limit,
|
|
)
|
|
|
|
fetcher = FlakySourceFetcher()
|
|
shard = CultNetShardDescriptor(
|
|
shard_id="background",
|
|
owner_runtime_id="background-primary",
|
|
epoch=7,
|
|
is_primary=False,
|
|
schema_ids=("mesh.background_replica_note.v1",),
|
|
primary_endpoints=("cultnet://127.0.0.1:3075",),
|
|
)
|
|
replicator = CultNetShardReplicator(
|
|
target.database,
|
|
CultNetShardReplicatorOptions(
|
|
fetcher=fetcher,
|
|
cursor_store=cursor_store,
|
|
batch_size=1,
|
|
poll_interval_seconds=0.01,
|
|
on_error=errors.append,
|
|
),
|
|
)
|
|
try:
|
|
source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="background", shard_epoch=7)
|
|
replicator.start([shard])
|
|
self._wait_until(lambda: target.database.get(document, "note:1") is not None)
|
|
source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="background", shard_epoch=7)
|
|
self._wait_until(lambda: target.database.get(document, "note:2") is not None)
|
|
finally:
|
|
replicator.stop()
|
|
|
|
cursor = cursor_store.read("background")
|
|
self.assertEqual(target.get_required(document, "note:1")["body"], "one")
|
|
self.assertEqual(target.get_required(document, "note:2")["body"], "two")
|
|
self.assertIsNotNone(cursor)
|
|
assert cursor is not None
|
|
self.assertEqual(cursor.last_applied_sequence, 2)
|
|
self.assertEqual(cursor.shard_epoch, 7)
|
|
self.assertEqual(len(errors), 1)
|
|
self.assertEqual(str(errors[0]), "transient fetch failure")
|
|
|
|
def test_cultnet_schema_write_forwarder_sends_raw_writes_to_primary(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.forward_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.forward_note.v1",
|
|
)
|
|
primary = CultMesh.create_node(runtime_id="forward-primary")
|
|
primary.database.register_document(document)
|
|
server = CultMesh.serve_node(primary)
|
|
try:
|
|
shard = CultNetShardDescriptor(
|
|
shard_id="forward",
|
|
owner_runtime_id="forward-primary",
|
|
epoch=9,
|
|
is_primary=False,
|
|
schema_ids=("mesh.forward_note.v1",),
|
|
primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
)
|
|
forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0)
|
|
put = document_put_raw(
|
|
message_id="forward-put",
|
|
key="note:forward",
|
|
schema_id=document.catalog_entry().schema_id,
|
|
stored_at="2026-06-14T00:00:00Z",
|
|
payload=document.encode_payload({"body": "forwarded"}),
|
|
).to_wire()
|
|
delete = document_delete(
|
|
message_id="forward-delete",
|
|
schema_id=document.catalog_entry().schema_id,
|
|
record_key="note:forward",
|
|
).to_wire()
|
|
|
|
forwarder.forward_put(shard, put)
|
|
self._eventually(lambda: primary.database.get(document, "note:forward") is not None)
|
|
self.assertEqual(primary.database.get_required(document, "note:forward")["body"], "forwarded")
|
|
forwarder.forward_delete(shard, delete)
|
|
self._eventually(lambda: primary.database.get(document, "note:forward") is None)
|
|
|
|
log = primary.database.build_shard_log_response(shard_id="forward", shard_epoch=9)
|
|
self.assertIsNone(primary.database.get(document, "note:forward"))
|
|
self.assertEqual([entry.change_kind for entry in log.entries], ["added", "removed"])
|
|
self.assertEqual(log.entries[0].put["shardId"], "forward")
|
|
self.assertEqual(log.entries[1].delete["shardEpoch"], 9)
|
|
finally:
|
|
server.stop()
|
|
|
|
def _eventually(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None:
|
|
deadline = time.monotonic() + timeout_seconds
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(0.01)
|
|
self.assertTrue(predicate())
|
|
|
|
def test_cultmesh_database_watchers_observe_name_and_index_changes(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.named_watch",
|
|
[("name", 0), ("kind", 1)],
|
|
name="name",
|
|
indexes={"kind": "kind"},
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-named-watch")
|
|
node.database.register_document(document)
|
|
name_changes: list[CultMeshDatabaseChange] = []
|
|
index_changes: list[CultMeshDatabaseChange] = []
|
|
|
|
node.database.watch_by_name(document, "Potion", name_changes.append)
|
|
node.database.watch_by_index(document, "kind", "consumable", index_changes.append)
|
|
|
|
node.database.put(document, "item:1", {"name": "Potion", "kind": "consumable"})
|
|
node.database.put(document, "item:2", {"name": "Sword", "kind": "weapon"})
|
|
node.database.put(document, "item:1", {"name": "Elixir", "kind": "rare"})
|
|
node.database.delete(document, "item:1")
|
|
|
|
self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"])
|
|
self.assertEqual([change.record_key for change in name_changes], ["item:1", "item:1"])
|
|
self.assertEqual(name_changes[1].previous_value, {"name": "Potion", "kind": "consumable"})
|
|
self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"])
|
|
self.assertEqual([change.record_key for change in index_changes], ["item:1", "item:1"])
|
|
self.assertEqual(index_changes[1].value, {"name": "Elixir", "kind": "rare"})
|
|
|
|
def test_cultmesh_database_collection_reads_resolve_same_schema_aliases(self) -> None:
|
|
@dataclass
|
|
class CanonicalItem:
|
|
name: str
|
|
kind: str
|
|
|
|
@dataclass
|
|
class UiItem:
|
|
name: str
|
|
kind: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.alias_collection_item",
|
|
[("name", 0), ("kind", 1)],
|
|
cls=CanonicalItem,
|
|
name="name",
|
|
indexes={"kind": "kind"},
|
|
schema_id="mesh.alias_collection_item.v1",
|
|
schema_name="mesh.alias_collection_item",
|
|
schema_version="mesh.alias_collection_item.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.alias_collection_item.ui",
|
|
[("name", 0), ("kind", 1)],
|
|
cls=UiItem,
|
|
name="name",
|
|
indexes={"kind": "kind"},
|
|
schema_id="mesh.alias_collection_item.v1",
|
|
schema_name="mesh.alias_collection_item",
|
|
schema_version="mesh.alias_collection_item.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-alias-collection")
|
|
node.database.register_document(document)
|
|
|
|
node.database.put(alias, "item:potion", UiItem("Potion", "consumable"))
|
|
node.database.put(document, "item:sword", CanonicalItem("Sword", "weapon"))
|
|
|
|
all_items = node.database.get_all(alias)
|
|
potion = node.database.get_by_name(alias, "Potion")
|
|
sword = node.get_by_index(alias, "kind", "weapon")
|
|
|
|
self.assertEqual([item.name for item in all_items], ["Potion", "Sword"])
|
|
self.assertTrue(all(isinstance(item, UiItem) for item in all_items))
|
|
self.assertIsInstance(potion, UiItem)
|
|
self.assertEqual(potion.kind, "consumable")
|
|
self.assertIsInstance(sword, UiItem)
|
|
self.assertEqual(sword.name, "Sword")
|
|
self.assertIsNone(node.database.get_by_name(alias, "Missing"))
|
|
|
|
def test_cultmesh_database_alias_collection_watches_emit_alias_values(self) -> None:
|
|
@dataclass
|
|
class CanonicalItem:
|
|
name: str
|
|
kind: str
|
|
|
|
@dataclass
|
|
class UiItem:
|
|
name: str
|
|
kind: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.alias_collection_watch_item",
|
|
[("name", 0), ("kind", 1)],
|
|
cls=CanonicalItem,
|
|
name="name",
|
|
indexes={"kind": "kind"},
|
|
schema_id="mesh.alias_collection_watch_item.v1",
|
|
schema_name="mesh.alias_collection_watch_item",
|
|
schema_version="mesh.alias_collection_watch_item.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.alias_collection_watch_item.ui",
|
|
[("name", 0), ("kind", 1)],
|
|
cls=UiItem,
|
|
name="name",
|
|
indexes={"kind": "kind"},
|
|
schema_id="mesh.alias_collection_watch_item.v1",
|
|
schema_name="mesh.alias_collection_watch_item",
|
|
schema_version="mesh.alias_collection_watch_item.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-alias-collection-watch")
|
|
node.database.register_document(document)
|
|
name_changes: list[CultMeshDatabaseChange] = []
|
|
index_changes: list[CultMeshDatabaseChange] = []
|
|
|
|
node.database.watch_by_name(alias, "Potion", name_changes.append)
|
|
node.database.watch_by_index(alias, "kind", "consumable", index_changes.append)
|
|
|
|
node.database.put(document, "item:potion", CanonicalItem("Potion", "consumable"))
|
|
node.database.put(document, "item:potion", CanonicalItem("Elixir", "rare"))
|
|
|
|
self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"])
|
|
self.assertTrue(all(change.document.type == alias.type for change in name_changes))
|
|
self.assertIsInstance(name_changes[0].value, UiItem)
|
|
self.assertIsInstance(name_changes[1].value, UiItem)
|
|
self.assertIsInstance(name_changes[1].previous_value, UiItem)
|
|
self.assertEqual(name_changes[1].previous_value.name, "Potion")
|
|
self.assertEqual(name_changes[1].value.name, "Elixir")
|
|
self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"])
|
|
self.assertTrue(all(change.document.type == alias.type for change in index_changes))
|
|
self.assertIsInstance(index_changes[0].value, UiItem)
|
|
self.assertIsInstance(index_changes[1].previous_value, UiItem)
|
|
|
|
def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None:
|
|
document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)])
|
|
node = CultMesh.create_node(runtime_id="mesh-watch-validation")
|
|
node.database.register_document(document)
|
|
|
|
with self.assertRaises(ValueError):
|
|
node.database.watch_by_name(document, "missing", lambda _: None)
|
|
with self.assertRaises(ValueError):
|
|
node.database.watch_by_index(document, "missing", "value", lambda _: None)
|
|
with self.assertRaises(ValueError):
|
|
node.database.watch_by_index(document, "", "value", lambda _: None)
|
|
with self.assertRaises(ValueError):
|
|
node.database.watch_by_index(document, "missing", "", lambda _: None)
|
|
|
|
def test_cultmesh_facade_matches_peer_runtime_entrypoints(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
store_path = Path(tmp) / "mesh.cc"
|
|
node = CultMesh.start_node(store_path, runtime_id="mesh-facade")
|
|
|
|
self.assertEqual(node.runtime_id, "mesh-facade")
|
|
self.assertIsInstance(CultMesh.create_node(), type(node))
|
|
self.assertIsInstance(CultMesh.create_verse_catalog(), CultMeshVerseCatalog)
|
|
self.assertIsInstance(CultMesh.create_peer_catalog(), CultMeshPeerCatalog)
|
|
self.assertIsInstance(CultMesh.create_authority_lease_catalog(), CultMeshAuthorityLeaseCatalog)
|
|
self.assertIsInstance(CultMesh.create_stream_catalog(), CultMeshStreamCatalog)
|
|
self.assertIsInstance(CultMesh.create_schema_catalog(), CultNetSchemaCatalog)
|
|
self.assertIsInstance(CultMesh.create_builtin_schema_catalog(), CultNetSchemaCatalog)
|
|
self.assertIsInstance(CultMesh.create_shard_catalog(), CultNetShardCatalog)
|
|
builtins = CultMesh.create_builtin_schema_catalog(
|
|
include_schema_json=False,
|
|
kinds=["shared_contract"],
|
|
)
|
|
self.assertEqual(
|
|
builtins.list()[0].schema_id,
|
|
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
|
|
)
|
|
self.assertIsNone(builtins.list()[0].schema_json)
|
|
peer_exchange_schema_id = (
|
|
"https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.peer-exchange-request.schema.json"
|
|
)
|
|
filtered_builtin = CultMesh.create_builtin_schema_catalog(
|
|
include_schema_json=True,
|
|
schema_ids=[peer_exchange_schema_id],
|
|
)
|
|
self.assertEqual(
|
|
filtered_builtin.list()[0].schema_version,
|
|
"cultmesh.peer_exchange_request.v0",
|
|
)
|
|
self.assertIn(
|
|
"cultmesh.peer_exchange_request.v0",
|
|
filtered_builtin.list()[0].schema_json or "",
|
|
)
|
|
shard_catalog = CultMesh.create_shard_catalog()
|
|
shard_catalog.upsert(
|
|
CultNetShardDescriptor(
|
|
shard_id="python-notes-a",
|
|
owner_runtime_id="mesh-facade",
|
|
epoch=7,
|
|
is_primary=True,
|
|
schema_ids=("python.note.v1",),
|
|
key_prefix="note:",
|
|
)
|
|
)
|
|
self.assertEqual(
|
|
[
|
|
shard.shard_id
|
|
for shard in shard_catalog.list(schema_ids=["python.note.v1"], record_keys=["note:1"])
|
|
],
|
|
["python-notes-a"],
|
|
)
|
|
verse_client = CultMesh.create_verse_discovery_client("127.0.0.1", 4010, timeout_seconds=1.5)
|
|
peer_client = CultMesh.create_peer_exchange_client("127.0.0.1", 4011, timeout_seconds=1.5)
|
|
raw_client = CultMesh.create_client("127.0.0.1", 4012, timeout_seconds=1.5)
|
|
connected_client = CultMesh.connect_client("127.0.0.1", 4013, timeout_seconds=1.5)
|
|
self.assertIsInstance(verse_client, CultMeshVerseDiscoveryClient)
|
|
self.assertIsInstance(peer_client, CultMeshPeerExchangeClient)
|
|
self.assertIsInstance(verse_client, CultMeshDiscoveryClient)
|
|
self.assertIsInstance(peer_client, CultMeshDiscoveryClient)
|
|
self.assertIsInstance(raw_client, CultNetRawClient)
|
|
self.assertIsInstance(connected_client, CultNetRawClient)
|
|
self.assertEqual((verse_client.host, verse_client.port, verse_client.timeout_seconds), ("127.0.0.1", 4010, 1.5))
|
|
self.assertEqual((peer_client.host, peer_client.port, peer_client.timeout_seconds), ("127.0.0.1", 4011, 1.5))
|
|
self.assertEqual((raw_client.host, raw_client.port, raw_client.timeout_seconds), ("127.0.0.1", 4012, 1.5))
|
|
self.assertEqual((connected_client.host, connected_client.port, connected_client.timeout_seconds), ("127.0.0.1", 4013, 1.5))
|
|
|
|
def test_cultmesh_simulation_fact_uses_csharp_slot_contract(self) -> None:
|
|
import msgpack # type: ignore
|
|
|
|
candidate = {
|
|
"shardId": "arena",
|
|
"shardEpoch": 4,
|
|
"frame": 100,
|
|
"subjectId": "bob",
|
|
"claimKind": "hit",
|
|
"claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
|
|
"claimSummary": "alice shot bob first",
|
|
"witnessCount": 2,
|
|
"supportWeight": 2.0,
|
|
"totalWeight": 2.0,
|
|
"confidence": 1.0,
|
|
"hasQuorum": True,
|
|
}
|
|
|
|
fact = CultMeshSimulationFact.from_candidate(candidate, committed_at="2026-06-13T00:00:00Z")
|
|
payload = simulation_fact_document.encode_payload(fact)
|
|
slots = msgpack.unpackb(payload, raw=False)
|
|
|
|
self.assertEqual(CultMeshSimulationFact.create_record_key(candidate), f"simulation:{fact.fact_id}")
|
|
self.assertEqual(slots[0], fact.fact_id)
|
|
self.assertEqual(slots[1], "arena")
|
|
self.assertEqual(slots[2], 4)
|
|
self.assertEqual(slots[3], 100)
|
|
self.assertEqual(slots[4], "bob")
|
|
self.assertEqual(slots[5], "hit")
|
|
self.assertEqual(slots[6], candidate["claimHash"])
|
|
self.assertEqual(slots[7], "alice shot bob first")
|
|
self.assertEqual(slots[8], 2)
|
|
self.assertEqual(slots[9], 2.0)
|
|
self.assertEqual(slots[10], 2.0)
|
|
self.assertEqual(slots[11], 1.0)
|
|
self.assertEqual(slots[12], "2026-06-13T00:00:00Z")
|
|
|
|
def test_cultmesh_simulation_fact_committer_rejects_without_quorum_and_stores_fact(self) -> None:
|
|
candidate = {
|
|
"shardId": "arena",
|
|
"shardEpoch": 4,
|
|
"frame": 100,
|
|
"subjectId": "bob",
|
|
"claimKind": "hit",
|
|
"claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
|
|
"claimSummary": "alice shot bob first",
|
|
"witnessCount": 2,
|
|
"supportWeight": 2.0,
|
|
"totalWeight": 2.0,
|
|
"confidence": 1.0,
|
|
"hasQuorum": True,
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
node = CultMesh.create_node(Path(tmp) / "facts.cc", runtime_id="mesh-facts")
|
|
committer = CultMesh.create_simulation_fact_committer(node)
|
|
|
|
rejected = dict(candidate)
|
|
rejected["hasQuorum"] = False
|
|
with self.assertRaisesRegex(ValueError, "before quorum"):
|
|
committer.commit(rejected)
|
|
|
|
committed = committer.commit(candidate, committed_at="2026-06-13T00:00:00Z")
|
|
stored = node.get_required(simulation_fact_document, committed.key)
|
|
|
|
self.assertEqual(stored.claim_hash, candidate["claimHash"])
|
|
self.assertEqual(stored.committed_at, "2026-06-13T00:00:00Z")
|
|
self.assertEqual(committed.fact.fact_id, stored.fact_id)
|
|
|
|
def test_cultmesh_game_session_submits_observations_and_commits_quorum_once(self) -> None:
|
|
claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
|
|
first = simulation_observation(
|
|
message_id="obs-1",
|
|
witness_runtime_id="watcher-1",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice shot bob first",
|
|
).to_wire()
|
|
second = simulation_observation(
|
|
message_id="obs-2",
|
|
witness_runtime_id="watcher-2",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice shot bob first",
|
|
).to_wire()
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
node = CultMesh.create_node(Path(tmp) / "session.cc", runtime_id="mesh-session")
|
|
session = CultMesh.create_game_session(
|
|
node,
|
|
CultMeshGameSessionOptions(
|
|
consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0)
|
|
),
|
|
)
|
|
seen_candidates: list[CultNetSimulationConsensusCandidate] = []
|
|
seen_facts: list[CultMeshDatabaseChange] = []
|
|
unsubscribe_candidates = session.watch_candidates(seen_candidates.append)
|
|
unsubscribe_facts = session.watch_simulation_facts(seen_facts.append)
|
|
|
|
self.assertEqual(session.submit_and_commit(CultNetSimulationObservation.from_wire(first)), [])
|
|
typed_candidates = session.submit_observation_candidates(CultNetSimulationObservation.from_wire(second))
|
|
commits = session.commit_quorum_candidate_objects(typed_candidates)
|
|
replay = session.commit_quorum_candidates(session.submit_observation(second))
|
|
unsubscribe_candidates()
|
|
unsubscribe_facts()
|
|
|
|
self.assertEqual(len(typed_candidates), 1)
|
|
self.assertTrue(typed_candidates[0].has_quorum)
|
|
self.assertEqual(len(commits), 1)
|
|
self.assertEqual(replay, [])
|
|
self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash, claim_hash])
|
|
self.assertEqual(len(seen_facts), 1)
|
|
self.assertEqual(seen_facts[0].change_kind, "added")
|
|
self.assertEqual(seen_facts[0].value.claim_hash, claim_hash)
|
|
stored = node.get_required(simulation_fact_document, commits[0].key)
|
|
self.assertEqual(stored.claim_hash, claim_hash)
|
|
self.assertEqual(stored.witness_count, 2)
|
|
|
|
def test_cultmesh_game_session_serves_catalogs_and_observations_over_wire(self) -> None:
|
|
verse_catalog = CultMeshVerseCatalog()
|
|
peer_catalog = CultMeshPeerCatalog()
|
|
verse_catalog.upsert(CultMeshVerseDescriptor(
|
|
verse_id="session-verse",
|
|
display_name="Session Verse",
|
|
authority_model="local",
|
|
compatibility=CultMeshVerseCompatibility("cultmesh.v0", "session-rules"),
|
|
))
|
|
peer_catalog.upsert(CultMeshPeerCard(
|
|
peer_id="session-peer",
|
|
verse_id="session-verse",
|
|
endpoints=("cultnet://127.0.0.1:0",),
|
|
roles=("simulation-observer", "read-replica"),
|
|
shard_ids=("arena",),
|
|
))
|
|
session = CultMesh.create_game_session(
|
|
CultMesh.create_node(runtime_id="session-peer"),
|
|
CultMeshGameSessionOptions(
|
|
verse_catalog=verse_catalog,
|
|
peer_catalog=peer_catalog,
|
|
consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0),
|
|
),
|
|
)
|
|
server = session.serve(display_name="Session Peer")
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
hello_response = raw_client.request(
|
|
hello(runtime_id="session-prober"),
|
|
expected_schema_version="cultnet.hello.v0",
|
|
)
|
|
discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
|
|
peers = discovery_client.fetch_peers(verse_id="session-verse", roles=["simulation-observer"])
|
|
claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
|
|
first = raw_client.request(
|
|
simulation_observation(
|
|
message_id="session-wire-1",
|
|
witness_runtime_id="watcher-1",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice shot bob first",
|
|
),
|
|
expected_schema_version="cultnet.simulation_consensus_candidate.v0",
|
|
)
|
|
second = raw_client.request(
|
|
simulation_observation(
|
|
message_id="session-wire-2",
|
|
witness_runtime_id="watcher-2",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice shot bob first",
|
|
),
|
|
expected_schema_version="cultnet.simulation_consensus_candidate.v0",
|
|
)
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
|
|
self.assertEqual(verses[0].verse_id, "session-verse")
|
|
self.assertEqual(peers[0].peer_id, "session-peer")
|
|
self.assertIn("simulation-observer", peers[0].roles)
|
|
self.assertFalse(first["hasQuorum"])
|
|
self.assertEqual(first["witnessCount"], 1)
|
|
self.assertTrue(second["hasQuorum"])
|
|
self.assertEqual(second["witnessCount"], 2)
|
|
|
|
def test_cultmesh_game_session_can_disable_served_simulation_observations(self) -> None:
|
|
verse_catalog = CultMeshVerseCatalog()
|
|
verse_catalog.upsert(CultMeshVerseDescriptor(
|
|
verse_id="session-quiet",
|
|
display_name="Quiet Session",
|
|
authority_model="local",
|
|
compatibility=CultMeshVerseCompatibility("cultmesh.v0", "quiet-rules"),
|
|
))
|
|
session = CultMesh.create_game_session(
|
|
CultMesh.create_node(runtime_id="quiet-session-peer"),
|
|
CultMeshGameSessionOptions(
|
|
verse_catalog=verse_catalog,
|
|
serve_simulation_observations=False,
|
|
),
|
|
)
|
|
server = session.serve(display_name="Quiet Session Peer")
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
hello_response = raw_client.request(
|
|
hello(runtime_id="quiet-session-prober"),
|
|
expected_schema_version="cultnet.hello.v0",
|
|
)
|
|
discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
|
|
with self.assertRaisesRegex(CultNetPeerError, "Simulation observations are not enabled") as raised:
|
|
raw_client.request(
|
|
simulation_observation(
|
|
message_id="quiet-session-sim",
|
|
witness_runtime_id="watcher-1",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
|
|
),
|
|
expected_schema_version="cultnet.simulation_consensus_candidate.v0",
|
|
)
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
|
|
self.assertEqual(verses[0].verse_id, "session-quiet")
|
|
self.assertEqual(raised.exception.response["code"], "simulation_observations_disabled")
|
|
|
|
def test_cultmesh_game_session_prediction_requires_scope_and_reconciles_shard_log(self) -> None:
|
|
note_doc = define_database_entry_type(
|
|
"mesh.input",
|
|
[("body", 0)],
|
|
schema_id="mesh.input.v1",
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
node = CultMesh.create_node(Path(tmp) / "prediction.cc", runtime_id="client-a")
|
|
node.register_document(note_doc)
|
|
session = CultMesh.create_game_session(
|
|
node,
|
|
CultMeshGameSessionOptions(
|
|
client_authority_scopes=(
|
|
CultNetClientAuthorityScope(
|
|
"client-a",
|
|
schema_ids=("mesh.input.v1",),
|
|
key_prefix="input:client-a",
|
|
),
|
|
)
|
|
),
|
|
)
|
|
|
|
with self.assertRaisesRegex(ValueError, "does not have client prediction authority"):
|
|
session.predict(note_doc, "input:other", {"body": "nope"})
|
|
|
|
prediction = session.predict(note_doc, "input:client-a:move", {"body": "predicted"})
|
|
put = document_put_raw(
|
|
message_id="authoritative",
|
|
key=prediction.key,
|
|
schema_id=prediction.schema_id,
|
|
stored_at="2026-06-13T00:00:00Z",
|
|
payload=note_doc.encode_payload({"body": "authoritative"}),
|
|
shard_id="inputs",
|
|
shard_epoch=1,
|
|
)
|
|
authoritative_response = {
|
|
"schemaVersion": "cultnet.shard_log_response.v0",
|
|
"messageId": "inputs-log",
|
|
"shardId": "inputs",
|
|
"shardEpoch": 1,
|
|
"entries": [
|
|
{
|
|
"sequence": 1,
|
|
"committedAt": "2026-06-13T00:00:00Z",
|
|
"changeKind": "updated",
|
|
"put": put.to_wire(),
|
|
}
|
|
],
|
|
"resyncRequired": False,
|
|
}
|
|
changes = session.apply_shard_log_response(CultNetShardLogResponse.from_wire(authoritative_response))
|
|
|
|
self.assertEqual(changes[0].change_kind, "reconciled")
|
|
self.assertEqual(node.get_required(note_doc, prediction.key)["body"], "authoritative")
|
|
self.assertEqual(session.pending_predictions(), ())
|
|
self.assertEqual(session.resimulation_inputs(), ())
|
|
|
|
def test_cultmesh_game_session_partial_reconciliation_keeps_unmatched_predictions(self) -> None:
|
|
note_doc = define_database_entry_type(
|
|
"mesh.partial_input",
|
|
[("body", 0)],
|
|
schema_id="mesh.partial_input.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="client-partial")
|
|
node.register_document(note_doc)
|
|
session = CultMesh.create_game_session(
|
|
node,
|
|
CultMeshGameSessionOptions(
|
|
client_authority_scopes=(
|
|
CultNetClientAuthorityScope(
|
|
"client-partial",
|
|
schema_ids=("mesh.partial_input.v1",),
|
|
key_prefix="input:client-partial",
|
|
),
|
|
)
|
|
),
|
|
)
|
|
pending = session.predict(note_doc, "input:client-partial:pending", {"body": "pending-local"})
|
|
accepted = session.predict(note_doc, "input:client-partial:accepted", {"body": "accepted-local"})
|
|
put = document_put_raw(
|
|
message_id="authoritative-partial",
|
|
key=accepted.key,
|
|
schema_id=accepted.schema_id,
|
|
stored_at="2026-06-13T00:00:00Z",
|
|
payload=note_doc.encode_payload({"body": "accepted-authority"}),
|
|
shard_id="inputs",
|
|
shard_epoch=1,
|
|
)
|
|
|
|
changes = session.apply_shard_log_response({
|
|
"schemaVersion": "cultnet.shard_log_response.v0",
|
|
"messageId": "inputs-partial-log",
|
|
"shardId": "inputs",
|
|
"shardEpoch": 1,
|
|
"entries": [
|
|
{
|
|
"sequence": 1,
|
|
"committedAt": "2026-06-13T00:00:00Z",
|
|
"changeKind": "updated",
|
|
"put": put.to_wire(),
|
|
}
|
|
],
|
|
"resyncRequired": False,
|
|
})
|
|
|
|
self.assertEqual([(change.change_kind, change.record_key) for change in changes], [
|
|
("reconciled", accepted.key),
|
|
])
|
|
self.assertEqual(node.get_required(note_doc, accepted.key)["body"], "accepted-authority")
|
|
self.assertEqual(node.get_required(note_doc, pending.key)["body"], "pending-local")
|
|
self.assertEqual([prediction.key for prediction in session.pending_predictions()], [pending.key])
|
|
self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [pending.key])
|
|
|
|
def test_cultmesh_game_session_rolls_back_pending_predictions(self) -> None:
|
|
note_doc = define_database_entry_type(
|
|
"mesh.rollback_input",
|
|
[("body", 0)],
|
|
schema_id="mesh.rollback_input.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="client-rollback")
|
|
node.register_document(note_doc)
|
|
node.put(note_doc, "input:client-rollback:existing", {"body": "previous"})
|
|
session = CultMesh.create_game_session(
|
|
node,
|
|
CultMeshGameSessionOptions(
|
|
client_authority_scopes=(
|
|
CultNetClientAuthorityScope(
|
|
"client-rollback",
|
|
schema_ids=("mesh.rollback_input.v1",),
|
|
key_prefix="input:client-rollback",
|
|
),
|
|
)
|
|
),
|
|
)
|
|
|
|
existing = session.predict(note_doc, "input:client-rollback:existing", {"body": "predicted-existing"})
|
|
created = session.predict(note_doc, "input:client-rollback:new", {"body": "predicted-new"})
|
|
self.assertEqual(
|
|
[prediction.key for prediction in session.pending_predictions()],
|
|
["input:client-rollback:existing", "input:client-rollback:new"],
|
|
)
|
|
self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [
|
|
"input:client-rollback:existing",
|
|
"input:client-rollback:new",
|
|
])
|
|
|
|
restored = session.rollback_prediction(existing)
|
|
self.assertIsNotNone(restored)
|
|
assert restored is not None
|
|
self.assertEqual(restored.change_kind, "rolled_back")
|
|
self.assertEqual(restored.value, {"body": "previous"})
|
|
self.assertEqual(node.get_required(note_doc, "input:client-rollback:existing")["body"], "previous")
|
|
|
|
rolled_back = session.rollback_predictions()
|
|
self.assertEqual([(change.change_kind, change.record_key, change.value) for change in rolled_back], [
|
|
("rolled_back", created.key, None),
|
|
])
|
|
self.assertIsNone(node.get(note_doc, created.key))
|
|
self.assertEqual(session.pending_predictions(), ())
|
|
|
|
def test_cultmesh_game_session_authoritative_delete_rolls_back_prediction(self) -> None:
|
|
note_doc = define_database_entry_type(
|
|
"mesh.deleted_input",
|
|
[("body", 0)],
|
|
schema_id="mesh.deleted_input.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="client-delete")
|
|
node.register_document(note_doc)
|
|
session = CultMesh.create_game_session(
|
|
node,
|
|
CultMeshGameSessionOptions(
|
|
client_authority_scopes=(
|
|
CultNetClientAuthorityScope(
|
|
"client-delete",
|
|
schema_ids=("mesh.deleted_input.v1",),
|
|
key_prefix="input:client-delete",
|
|
),
|
|
)
|
|
),
|
|
)
|
|
|
|
prediction = session.predict(note_doc, "input:client-delete:move", {"body": "predicted"})
|
|
delete = document_delete(
|
|
message_id="authoritative-delete",
|
|
schema_id=prediction.schema_id,
|
|
record_key=prediction.key,
|
|
shard_id="inputs",
|
|
shard_epoch=1,
|
|
)
|
|
changes = session.apply_shard_log_response({
|
|
"schemaVersion": "cultnet.shard_log_response.v0",
|
|
"messageId": "inputs-delete-log",
|
|
"shardId": "inputs",
|
|
"shardEpoch": 1,
|
|
"entries": [
|
|
{
|
|
"sequence": 1,
|
|
"committedAt": "2026-06-13T00:00:00Z",
|
|
"changeKind": "removed",
|
|
"delete": delete.to_wire(),
|
|
}
|
|
],
|
|
"resyncRequired": False,
|
|
})
|
|
|
|
self.assertEqual(changes[0].change_kind, "rolled_back")
|
|
self.assertIsNone(node.get(note_doc, prediction.key))
|
|
self.assertEqual(session.pending_predictions(), ())
|
|
|
|
def test_cultmesh_database_watchers_observe_authoritative_shard_log_reconciliation(self) -> None:
|
|
note_doc = define_database_entry_type(
|
|
"mesh.watched_input",
|
|
[("body", 0)],
|
|
schema_id="mesh.watched_input.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="client-watch")
|
|
node.database.register_document(note_doc)
|
|
seen: list[CultMeshDatabaseChange] = []
|
|
node.database.watch_record(note_doc, "input:client-watch:move", seen.append)
|
|
session = CultMesh.create_game_session(
|
|
node,
|
|
CultMeshGameSessionOptions(
|
|
client_authority_scopes=(
|
|
CultNetClientAuthorityScope(
|
|
"client-watch",
|
|
schema_ids=("mesh.watched_input.v1",),
|
|
key_prefix="input:client-watch",
|
|
),
|
|
)
|
|
),
|
|
)
|
|
|
|
prediction = session.predict(note_doc, "input:client-watch:move", {"body": "predicted"})
|
|
put = document_put_raw(
|
|
message_id="authoritative-watch",
|
|
key=prediction.key,
|
|
schema_id=prediction.schema_id,
|
|
stored_at="2026-06-13T00:00:00Z",
|
|
payload=note_doc.encode_payload({"body": "authoritative"}),
|
|
shard_id="inputs",
|
|
shard_epoch=1,
|
|
)
|
|
session_changes = session.apply_shard_log_response({
|
|
"schemaVersion": "cultnet.shard_log_response.v0",
|
|
"messageId": "inputs-watch-log",
|
|
"shardId": "inputs",
|
|
"shardEpoch": 1,
|
|
"entries": [
|
|
{
|
|
"sequence": 1,
|
|
"committedAt": "2026-06-13T00:00:00Z",
|
|
"changeKind": "updated",
|
|
"put": put.to_wire(),
|
|
}
|
|
],
|
|
"resyncRequired": False,
|
|
})
|
|
|
|
self.assertEqual([change.change_kind for change in seen], ["added", "updated"])
|
|
self.assertEqual(seen[1].value, {"body": "authoritative"})
|
|
self.assertEqual(seen[1].previous_value, {"body": "predicted"})
|
|
self.assertEqual(session_changes[0].change_kind, "reconciled")
|
|
|
|
def test_cultmesh_verse_catalog_response_matches_schema_v0_wire_shape(self) -> None:
|
|
import msgpack # type: ignore
|
|
|
|
catalog = CultMeshVerseCatalog()
|
|
catalog.upsert(
|
|
CultMeshVerseDescriptor(
|
|
verse_id="aetheria-main",
|
|
display_name="Aetheria",
|
|
authority_model="OperatorCluster",
|
|
compatibility=CultMeshVerseCompatibility(
|
|
transport_version="cultmesh.v0",
|
|
rules_hash="rules",
|
|
compatible_verse_ids=("aetheria-modded",),
|
|
required_plugin_ids=("core",),
|
|
optional_plugin_ids=("skylands",),
|
|
),
|
|
discovery_endpoints=("cultmesh://aetheria.example.test:3075",),
|
|
authority_runtime_ids=("runtime-a",),
|
|
description="main branch",
|
|
)
|
|
)
|
|
|
|
response = catalog.create_response(verse_catalog_request("verses-1"))
|
|
decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False)
|
|
self.assertEqual(decoded["schemaVersion"], "cultmesh.verse_catalog_response.v0")
|
|
self.assertEqual(decoded["messageId"], "verses-1")
|
|
self.assertEqual(decoded["verses"][0]["verseId"], "aetheria-main")
|
|
self.assertEqual(decoded["verses"][0]["compatibility"]["requiredPluginIds"], ["core"])
|
|
|
|
def test_cultmesh_verse_catalog_watches_updates_and_finds_transfer_targets(self) -> None:
|
|
source = CultMeshVerseDescriptor(
|
|
verse_id="aetheria-main",
|
|
display_name="Aetheria",
|
|
authority_model="federated",
|
|
compatibility=CultMeshVerseCompatibility(
|
|
transport_version="cultmesh.v0",
|
|
rules_hash="rules-main",
|
|
),
|
|
)
|
|
compatible = CultMeshVerseDescriptor(
|
|
verse_id="aetheria-modded",
|
|
display_name="Aetheria Modded",
|
|
authority_model="federated",
|
|
compatibility=CultMeshVerseCompatibility(
|
|
transport_version="cultmesh.v0",
|
|
rules_hash="rules-modded",
|
|
compatible_verse_ids=("aetheria-main",),
|
|
),
|
|
)
|
|
incompatible = CultMeshVerseDescriptor(
|
|
verse_id="old-world",
|
|
display_name="Old World",
|
|
authority_model="solo",
|
|
compatibility=CultMeshVerseCompatibility(
|
|
transport_version="cultmesh.v0",
|
|
rules_hash="rules-old",
|
|
),
|
|
)
|
|
catalog = CultMeshVerseCatalog()
|
|
seen: list[str] = []
|
|
unsubscribe = catalog.watch(lambda verse: seen.append(verse.verse_id))
|
|
|
|
catalog.upsert(source)
|
|
catalog.apply_response({
|
|
"schemaVersion": "cultmesh.verse_catalog_response.v0",
|
|
"messageId": "verses-watch",
|
|
"verses": [compatible.to_wire(), incompatible.to_wire()],
|
|
})
|
|
unsubscribe()
|
|
catalog.upsert(CultMeshVerseDescriptor(
|
|
verse_id="after-unsubscribe",
|
|
display_name="After",
|
|
authority_model="none",
|
|
compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-after"),
|
|
))
|
|
|
|
self.assertEqual(seen, ["aetheria-main", "aetheria-modded", "old-world"])
|
|
self.assertEqual([verse.verse_id for verse in catalog.verses], ["aetheria-main", "aetheria-modded", "after-unsubscribe", "old-world"])
|
|
self.assertEqual(catalog.get("aetheria-modded"), compatible)
|
|
self.assertEqual([verse.verse_id for verse in catalog.find_transfer_targets(source)], ["aetheria-modded"])
|
|
|
|
def test_cultmesh_peer_exchange_response_matches_schema_v0_wire_shape(self) -> None:
|
|
import msgpack # type: ignore
|
|
|
|
catalog = CultMeshPeerCatalog()
|
|
catalog.upsert(
|
|
CultMeshPeerCard(
|
|
peer_id="peer-a",
|
|
verse_id="aetheria-main",
|
|
endpoints=("cultnet://peer-a.example.test:3075",),
|
|
roles=("discovery", "read-replica"),
|
|
shard_ids=("players",),
|
|
region="eu-west",
|
|
authority_lease_id="lease-1",
|
|
expires_at="2026-05-20T12:00:00.0000000Z",
|
|
signature="sig",
|
|
)
|
|
)
|
|
|
|
response = catalog.create_response(
|
|
peer_exchange_request("pex-1", verse_id="aetheria-main", roles=["read-replica"])
|
|
)
|
|
decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False)
|
|
self.assertEqual(decoded["schemaVersion"], "cultmesh.peer_exchange_response.v0")
|
|
self.assertEqual(decoded["messageId"], "pex-1")
|
|
self.assertEqual(decoded["peers"][0]["peerId"], "peer-a")
|
|
self.assertIn("read-replica", decoded["peers"][0]["roles"])
|
|
self.assertEqual(decoded["peers"][0]["authorityLeaseId"], "lease-1")
|
|
|
|
def test_cultmesh_peer_catalog_watches_updates_and_gets_peers(self) -> None:
|
|
first = CultMeshPeerCard(
|
|
peer_id="peer-a",
|
|
verse_id="aetheria-main",
|
|
endpoints=("cultnet://peer-a.example.test:3075",),
|
|
roles=("discovery", "read-replica"),
|
|
)
|
|
second = CultMeshPeerCard(
|
|
peer_id="peer-b",
|
|
verse_id="aetheria-main",
|
|
endpoints=("cultnet://peer-b.example.test:3075",),
|
|
roles=("shard-primary",),
|
|
)
|
|
catalog = CultMeshPeerCatalog()
|
|
seen: list[str] = []
|
|
unsubscribe = catalog.watch(lambda peer: seen.append(peer.peer_id))
|
|
|
|
catalog.upsert(first)
|
|
catalog.apply_response({
|
|
"schemaVersion": "cultmesh.peer_exchange_response.v0",
|
|
"messageId": "peers-watch",
|
|
"peers": [second.to_wire()],
|
|
})
|
|
unsubscribe()
|
|
catalog.upsert(CultMeshPeerCard(
|
|
peer_id="peer-c",
|
|
verse_id="aetheria-main",
|
|
endpoints=("cultnet://peer-c.example.test:3075",),
|
|
roles=("read-replica",),
|
|
))
|
|
|
|
self.assertEqual(seen, ["peer-a", "peer-b"])
|
|
self.assertTrue(first.has_role("read-replica"))
|
|
self.assertEqual([peer.peer_id for peer in catalog.peers], ["peer-a", "peer-b", "peer-c"])
|
|
self.assertEqual(catalog.get("peer-b"), second)
|
|
self.assertEqual([peer.peer_id for peer in catalog.find("aetheria-main", role="read-replica")], ["peer-a", "peer-c"])
|
|
|
|
def test_cultmesh_discovery_client_fetches_typed_catalogs_over_cultnet_frames(self) -> None:
|
|
import msgpack # type: ignore
|
|
from cultnet_py import read_frame, write_frame
|
|
|
|
verses = CultMeshVerseCatalog()
|
|
verses.upsert(
|
|
CultMeshVerseDescriptor(
|
|
verse_id="aetheria-main",
|
|
display_name="Aetheria",
|
|
authority_model="federated",
|
|
compatibility=CultMeshVerseCompatibility(
|
|
transport_version="cultmesh.v0",
|
|
rules_hash="rules-1",
|
|
required_plugin_ids=("core",),
|
|
),
|
|
discovery_endpoints=("cultmesh://aetheria.example.test:3075",),
|
|
authority_runtime_ids=("runtime-a",),
|
|
)
|
|
)
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(
|
|
CultMeshPeerCard(
|
|
peer_id="peer-a",
|
|
verse_id="aetheria-main",
|
|
endpoints=("cultnet://peer-a.example.test:3075",),
|
|
roles=("read-replica",),
|
|
shard_ids=("players",),
|
|
)
|
|
)
|
|
|
|
ready = threading.Event()
|
|
server_error: list[BaseException] = []
|
|
|
|
def serve_requests() -> None:
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server.bind(("127.0.0.1", 0))
|
|
port_holder.append(server.getsockname()[1])
|
|
server.listen(8)
|
|
ready.set()
|
|
for _ in range(8):
|
|
connection, _ = server.accept()
|
|
with connection:
|
|
stream = connection.makefile("rwb")
|
|
request = msgpack.unpackb(read_frame(stream), raw=False)
|
|
if request["schemaVersion"] == "cultmesh.verse_catalog_request.v0":
|
|
response = verses.create_response(request)
|
|
elif request["schemaVersion"] == "cultmesh.peer_exchange_request.v0":
|
|
response = peers.create_response(request)
|
|
else:
|
|
raise AssertionError(f"unexpected request {request['schemaVersion']}")
|
|
write_frame(stream, msgpack.packb(response, use_bin_type=True))
|
|
stream.flush()
|
|
except BaseException as error:
|
|
server_error.append(error)
|
|
ready.set()
|
|
|
|
port_holder: list[int] = []
|
|
thread = threading.Thread(target=serve_requests, daemon=True)
|
|
thread.start()
|
|
self.assertTrue(ready.wait(2.0))
|
|
self.assertFalse(server_error)
|
|
|
|
client = CultMeshDiscoveryClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
|
|
fetched_verses = client.fetch_verses(transport_version="cultmesh.v0")
|
|
fetched_peers = client.fetch_peers(verse_id="aetheria-main", roles=["read-replica"])
|
|
local_verses = CultMeshVerseCatalog()
|
|
local_peers = CultMeshPeerCatalog()
|
|
synced_verses = client.sync_verse_catalog(local_verses, transport_version="cultmesh.v0")
|
|
synced_peers = client.sync_peer_catalog(local_peers, verse_id="aetheria-main", roles=["read-replica"])
|
|
discovered_verses = CultMeshVerseCatalog()
|
|
discovered_peers = CultMeshPeerCatalog()
|
|
endpoint = f"cultnet://127.0.0.1:{port_holder[0]}"
|
|
verse_discovery = CultMeshVerseDiscoveryClient.from_endpoint(endpoint, timeout_seconds=2.0)
|
|
peer_discovery = CultMeshPeerExchangeClient.from_endpoint(endpoint, timeout_seconds=2.0)
|
|
discovered_verse_count = verse_discovery.discover(
|
|
discovered_verses,
|
|
endpoints=[endpoint, endpoint, ""],
|
|
transport_version="cultmesh.v0",
|
|
)
|
|
discovered_peer_count = peer_discovery.discover(
|
|
discovered_peers,
|
|
verse_id="aetheria-main",
|
|
endpoints=[endpoint, endpoint, ""],
|
|
roles=["read-replica"],
|
|
)
|
|
fanout_verses = CultMeshVerseCatalog()
|
|
fanout_verses.upsert(
|
|
CultMeshVerseDescriptor(
|
|
verse_id="aetheria-main",
|
|
display_name="Known Aetheria",
|
|
authority_model="federated",
|
|
compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-1"),
|
|
discovery_endpoints=(endpoint, endpoint, ""),
|
|
)
|
|
)
|
|
fanout_peers = CultMeshPeerCatalog()
|
|
fanout_peers.upsert(
|
|
CultMeshPeerCard(
|
|
peer_id="known-peer",
|
|
verse_id="aetheria-main",
|
|
endpoints=(endpoint, endpoint, ""),
|
|
roles=("read-replica",),
|
|
)
|
|
)
|
|
fanout_verse_count = verse_discovery.fanout(fanout_verses, transport_version="cultmesh.v0")
|
|
fanout_peer_count = peer_discovery.fanout(
|
|
fanout_peers,
|
|
verse_id="aetheria-main",
|
|
roles=["read-replica"],
|
|
)
|
|
|
|
thread.join(2.0)
|
|
self.assertFalse(server_error)
|
|
self.assertEqual(fetched_verses[0].verse_id, "aetheria-main")
|
|
self.assertEqual(fetched_verses[0].compatibility.required_plugin_ids, ("core",))
|
|
self.assertEqual(fetched_peers[0].peer_id, "peer-a")
|
|
self.assertEqual(fetched_peers[0].shard_ids, ("players",))
|
|
self.assertEqual(synced_verses[0].verse_id, "aetheria-main")
|
|
self.assertEqual(local_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a")
|
|
self.assertEqual(synced_peers[0].roles, ("read-replica",))
|
|
self.assertEqual(discovered_verse_count, 1)
|
|
self.assertEqual(discovered_peer_count, 1)
|
|
self.assertEqual(discovered_verses.get("aetheria-main").display_name, "Aetheria")
|
|
self.assertEqual(discovered_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a")
|
|
self.assertEqual(fanout_verse_count, 1)
|
|
self.assertEqual(fanout_peer_count, 1)
|
|
self.assertEqual(fanout_verses.get("aetheria-main").display_name, "Aetheria")
|
|
self.assertIn(
|
|
"peer-a",
|
|
{peer.peer_id for peer in fanout_peers.find("aetheria-main", role="read-replica")},
|
|
)
|
|
|
|
def test_cultmesh_discovery_client_fans_out_simulation_observations_to_peers(self) -> None:
|
|
first_hub = CultNetSimulationObservationHub(
|
|
CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
|
|
)
|
|
second_hub = CultNetSimulationObservationHub(
|
|
CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
|
|
)
|
|
first_server = CultMesh.serve_node(
|
|
CultMesh.create_node(runtime_id="sim-peer-a"),
|
|
observation_hub=first_hub,
|
|
)
|
|
second_server = CultMesh.serve_node(
|
|
CultMesh.create_node(runtime_id="sim-peer-b"),
|
|
observation_hub=second_hub,
|
|
)
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="sim-peer-a",
|
|
verse_id="arena",
|
|
endpoints=(f"cultnet://127.0.0.1:{first_server.port}",),
|
|
roles=("simulation-witness",),
|
|
))
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="sim-peer-b",
|
|
verse_id="arena",
|
|
endpoints=(f"cultnet://127.0.0.1:{second_server.port}",),
|
|
roles=("simulation-witness",),
|
|
))
|
|
claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:200")
|
|
message = simulation_observation(
|
|
message_id="fanout-observation",
|
|
witness_runtime_id="python-fanout",
|
|
shard_id="arena",
|
|
shard_epoch=1,
|
|
frame=200,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice hit bob",
|
|
).to_wire()
|
|
|
|
client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0)
|
|
candidates = client.fanout_simulation_observation(
|
|
peers,
|
|
message,
|
|
verse_id="arena",
|
|
roles=["simulation-witness"],
|
|
)
|
|
finally:
|
|
first_server.stop()
|
|
second_server.stop()
|
|
|
|
self.assertEqual([candidate["schemaVersion"] for candidate in candidates], [
|
|
"cultnet.simulation_consensus_candidate.v0",
|
|
"cultnet.simulation_consensus_candidate.v0",
|
|
])
|
|
self.assertEqual({candidate["claimHash"] for candidate in candidates}, {claim_hash})
|
|
self.assertEqual({candidate["messageId"] for candidate in candidates}, {"fanout-observation"})
|
|
self.assertEqual({candidate["hasQuorum"] for candidate in candidates}, {True})
|
|
|
|
def test_cultmesh_simulation_observation_fanout_flushes_queued_messages(self) -> None:
|
|
hub = CultNetSimulationObservationHub(
|
|
CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
|
|
)
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-flush-peer"), observation_hub=hub)
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="sim-flush-peer",
|
|
verse_id="arena",
|
|
endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
roles=("simulation-witness",),
|
|
))
|
|
claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:210")
|
|
seen_candidates: list[dict[str, object]] = []
|
|
fanout = CultMeshSimulationObservationFanout(
|
|
CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
|
|
peers,
|
|
verse_id="arena",
|
|
roles=["simulation-witness"],
|
|
on_candidate=seen_candidates.append,
|
|
)
|
|
fanout.enqueue(simulation_observation(
|
|
message_id="queued-observation",
|
|
witness_runtime_id="python-fanout",
|
|
shard_id="arena",
|
|
shard_epoch=1,
|
|
frame=210,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice hit bob",
|
|
).to_wire())
|
|
|
|
candidates = fanout.flush()
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual(fanout.pending_count(), 0)
|
|
self.assertEqual([candidate["claimHash"] for candidate in candidates], [claim_hash])
|
|
self.assertEqual(seen_candidates[0]["messageId"], "queued-observation")
|
|
|
|
def test_cultmesh_simulation_observation_fanout_keeps_failed_messages_pending(self) -> None:
|
|
closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
closed_socket.bind(("127.0.0.1", 0))
|
|
closed_port = closed_socket.getsockname()[1]
|
|
closed_socket.close()
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="closed-simulation-peer",
|
|
verse_id="arena",
|
|
endpoints=(f"cultnet://127.0.0.1:{closed_port}",),
|
|
roles=("simulation-witness",),
|
|
))
|
|
errors: list[tuple[str, str]] = []
|
|
fanout = CultMeshSimulationObservationFanout(
|
|
CultMeshDiscoveryClient("127.0.0.1", closed_port, timeout_seconds=0.2),
|
|
peers,
|
|
verse_id="arena",
|
|
roles=["simulation-witness"],
|
|
on_error=lambda endpoint, error: errors.append((endpoint, str(error))),
|
|
)
|
|
fanout.enqueue(simulation_observation(
|
|
message_id="retry-observation",
|
|
witness_runtime_id="python-fanout",
|
|
shard_id="arena",
|
|
shard_epoch=1,
|
|
frame=212,
|
|
subject_id="bob",
|
|
claim_kind="miss",
|
|
claim_hash=compute_simulation_claim_hash("miss", "alice", "bob", "frame:212"),
|
|
claim_summary="alice missed bob",
|
|
).to_wire())
|
|
|
|
candidates = fanout.flush()
|
|
|
|
self.assertEqual(candidates, [])
|
|
self.assertEqual(fanout.pending_count(), 1)
|
|
self.assertEqual(len(errors), 1)
|
|
self.assertIn(f"127.0.0.1:{closed_port}", errors[0][0])
|
|
|
|
def test_cultmesh_simulation_observation_fanout_runs_background_loop(self) -> None:
|
|
hub = CultNetSimulationObservationHub(
|
|
CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
|
|
)
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-loop-peer"), observation_hub=hub)
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="sim-loop-peer",
|
|
verse_id="arena",
|
|
endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
roles=("simulation-witness",),
|
|
))
|
|
claim_hash = compute_simulation_claim_hash("block", "dana", "eve", "frame:211")
|
|
seen_candidates: list[dict[str, object]] = []
|
|
fanout = CultMeshSimulationObservationFanout(
|
|
CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
|
|
peers,
|
|
verse_id="arena",
|
|
roles=["simulation-witness"],
|
|
interval_seconds=0.05,
|
|
on_candidate=seen_candidates.append,
|
|
)
|
|
fanout.start()
|
|
fanout.enqueue(simulation_observation(
|
|
message_id="loop-observation",
|
|
witness_runtime_id="python-fanout",
|
|
shard_id="arena",
|
|
shard_epoch=1,
|
|
frame=211,
|
|
subject_id="eve",
|
|
claim_kind="block",
|
|
claim_hash=claim_hash,
|
|
claim_summary="dana blocked eve",
|
|
).to_wire())
|
|
deadline = time.monotonic() + 2.0
|
|
while not seen_candidates and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
fanout.stop()
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual(fanout.pending_count(), 0)
|
|
self.assertTrue(seen_candidates)
|
|
self.assertEqual(seen_candidates[0]["claimHash"], claim_hash)
|
|
self.assertEqual(seen_candidates[0]["messageId"], "loop-observation")
|
|
|
|
def test_cultmesh_discovery_client_fans_out_snapshots_to_peers(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.snapshot_fanout_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.snapshot_fanout_note.v1",
|
|
)
|
|
first = CultMesh.create_node(runtime_id="snapshot-peer-a")
|
|
first.database.register_document(document)
|
|
first.database.put_raw_message(document, "note:a", {"body": "from-a"}, shard_id="notes", shard_epoch=1)
|
|
second = CultMesh.create_node(runtime_id="snapshot-peer-b")
|
|
second.database.register_document(document)
|
|
second.database.put_raw_message(document, "note:b", {"body": "from-b"}, shard_id="notes", shard_epoch=1)
|
|
target = CultMesh.create_node(runtime_id="snapshot-target")
|
|
target.database.register_document(document)
|
|
first_server = CultMesh.serve_node(first)
|
|
second_server = CultMesh.serve_node(second)
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="snapshot-peer-a",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{first_server.port}", f"cultnet://127.0.0.1:{first_server.port}"),
|
|
roles=("read-replica",),
|
|
))
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="snapshot-peer-b",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{second_server.port}",),
|
|
roles=("read-replica",),
|
|
))
|
|
|
|
client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0)
|
|
responses = client.fanout_snapshot_responses(
|
|
peers,
|
|
verse_id="mesh",
|
|
roles=["read-replica"],
|
|
schema_ids=["mesh.snapshot_fanout_note.v1"],
|
|
)
|
|
applied = client.sync_snapshots(
|
|
target.database,
|
|
peers,
|
|
verse_id="mesh",
|
|
roles=["read-replica"],
|
|
schema_ids=["mesh.snapshot_fanout_note.v1"],
|
|
)
|
|
finally:
|
|
first_server.stop()
|
|
second_server.stop()
|
|
|
|
self.assertEqual(len(responses), 2)
|
|
self.assertTrue(all(isinstance(response, CultNetRawSnapshotResponse) for response in responses))
|
|
self.assertEqual(
|
|
sorted(record.record_key for response in responses for record in response.documents),
|
|
["note:a", "note:b"],
|
|
)
|
|
self.assertEqual(sorted(record.record_key for record in applied), ["note:a", "note:b"])
|
|
self.assertEqual(target.database.get_required(document, "note:a")["body"], "from-a")
|
|
self.assertEqual(target.database.get_required(document, "note:b")["body"], "from-b")
|
|
|
|
def test_cultmesh_discovery_client_sync_documents_returns_requested_aliases(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.snapshot_fanout_alias_note",
|
|
[("body", 0)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.snapshot_fanout_alias_note.v1",
|
|
schema_name="mesh.snapshot_fanout_alias_note",
|
|
schema_version="mesh.snapshot_fanout_alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.snapshot_fanout_alias_note.ui",
|
|
[("body", 0)],
|
|
cls=UiNote,
|
|
schema_id="mesh.snapshot_fanout_alias_note.v1",
|
|
schema_name="mesh.snapshot_fanout_alias_note",
|
|
schema_version="mesh.snapshot_fanout_alias_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="snapshot-alias-source")
|
|
source.database.register_document(document)
|
|
source.database.put_raw_message(document, "note:alias", CanonicalNote("from-source"))
|
|
target = CultMesh.create_node(runtime_id="snapshot-alias-target")
|
|
target.database.register_document(document)
|
|
server = CultMesh.serve_node(source)
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="snapshot-alias-source",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
roles=("read-replica",),
|
|
))
|
|
|
|
values = CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0).sync_documents(
|
|
target.database,
|
|
peers,
|
|
verse_id="mesh",
|
|
roles=["read-replica"],
|
|
documents=[(alias, "note:alias")],
|
|
)
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual(len(values), 1)
|
|
self.assertIsInstance(values[0], UiNote)
|
|
self.assertEqual(values[0].body, "from-source")
|
|
self.assertEqual(target.database.get_required(document, "note:alias").body, "from-source")
|
|
self.assertIsInstance(target.database.get_required(alias, "note:alias"), UiNote)
|
|
|
|
def test_cultmesh_document_subscription_syncs_alias_snapshot_and_changes(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.subscription_alias_note",
|
|
[("body", 0)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.subscription_alias_note.v1",
|
|
schema_name="mesh.subscription_alias_note",
|
|
schema_version="mesh.subscription_alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.subscription_alias_note.ui",
|
|
[("body", 0)],
|
|
cls=UiNote,
|
|
schema_id="mesh.subscription_alias_note.v1",
|
|
schema_name="mesh.subscription_alias_note",
|
|
schema_version="mesh.subscription_alias_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="subscription-alias-source")
|
|
source.database.register_document(document)
|
|
source.database.put_raw_message(document, "note:live", CanonicalNote("initial"))
|
|
target = CultMesh.create_node(runtime_id="subscription-alias-target")
|
|
target.database.register_document(document)
|
|
server = CultMesh.serve_node(source)
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription:
|
|
initial = subscription.sync_initial()
|
|
subscription.send(document_put_raw(
|
|
message_id="subscription-alias-put",
|
|
key="note:live",
|
|
schema_id="mesh.subscription_alias_note.v1",
|
|
stored_at="2026-06-14T00:00:00Z",
|
|
payload=document.encode_payload(CanonicalNote("updated")),
|
|
))
|
|
updated = subscription.read_next_change()
|
|
subscription.send(document_delete(
|
|
message_id="subscription-alias-delete",
|
|
schema_id="mesh.subscription_alias_note.v1",
|
|
record_key="note:live",
|
|
))
|
|
removed = subscription.read_next_change()
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertIsInstance(initial, UiNote)
|
|
self.assertEqual(initial.body, "initial")
|
|
self.assertEqual(updated.change_kind, "updated")
|
|
self.assertIsInstance(updated.value, UiNote)
|
|
self.assertEqual(updated.value.body, "updated")
|
|
self.assertIsInstance(updated.previous_value, UiNote)
|
|
self.assertEqual(updated.previous_value.body, "initial")
|
|
self.assertEqual(removed.change_kind, "removed")
|
|
self.assertIsNone(removed.value)
|
|
self.assertIsInstance(removed.previous_value, UiNote)
|
|
self.assertEqual(removed.previous_value.body, "updated")
|
|
self.assertIsNone(target.database.get(alias, "note:live"))
|
|
|
|
def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.snapshot_loop_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.snapshot_loop_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="snapshot-loop-source")
|
|
source.database.register_document(document)
|
|
source.database.put_raw_message(document, "note:sync-once", {"body": "from-source"})
|
|
target = CultMesh.create_node(runtime_id="snapshot-loop-target")
|
|
target.database.register_document(document)
|
|
server = CultMesh.serve_node(source)
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="snapshot-loop-source",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
roles=("read-replica",),
|
|
))
|
|
seen: list[str] = []
|
|
fanout = CultMeshSnapshotFanout(
|
|
CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
|
|
target.database,
|
|
peers,
|
|
verse_id="mesh",
|
|
roles=["read-replica"],
|
|
schema_ids=["mesh.snapshot_loop_note.v1"],
|
|
on_applied=lambda record: seen.append(record.record_key),
|
|
)
|
|
|
|
applied = fanout.sync_once()
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual([record.record_key for record in applied], ["note:sync-once"])
|
|
self.assertEqual(seen, ["note:sync-once"])
|
|
self.assertEqual(target.database.get_required(document, "note:sync-once")["body"], "from-source")
|
|
|
|
def test_cultmesh_snapshot_fanout_syncs_typed_documents_and_reports_alias_values(self) -> None:
|
|
@dataclass
|
|
class CanonicalNote:
|
|
body: str
|
|
|
|
@dataclass
|
|
class UiNote:
|
|
body: str
|
|
|
|
document = define_database_entry_type(
|
|
"mesh.snapshot_loop_alias_note",
|
|
[("body", 0)],
|
|
cls=CanonicalNote,
|
|
schema_id="mesh.snapshot_loop_alias_note.v1",
|
|
schema_name="mesh.snapshot_loop_alias_note",
|
|
schema_version="mesh.snapshot_loop_alias_note.v1",
|
|
)
|
|
alias = define_database_entry_type(
|
|
"mesh.snapshot_loop_alias_note.ui",
|
|
[("body", 0)],
|
|
cls=UiNote,
|
|
schema_id="mesh.snapshot_loop_alias_note.v1",
|
|
schema_name="mesh.snapshot_loop_alias_note",
|
|
schema_version="mesh.snapshot_loop_alias_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="snapshot-loop-alias-source")
|
|
source.database.register_document(document)
|
|
source.database.put_raw_message(document, "note:typed-loop", CanonicalNote("from-source"))
|
|
target = CultMesh.create_node(runtime_id="snapshot-loop-alias-target")
|
|
target.database.register_document(document)
|
|
server = CultMesh.serve_node(source)
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="snapshot-loop-alias-source",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
roles=("read-replica",),
|
|
))
|
|
seen: list[UiNote] = []
|
|
fanout = CultMeshSnapshotFanout(
|
|
CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
|
|
target.database,
|
|
peers,
|
|
verse_id="mesh",
|
|
roles=["read-replica"],
|
|
documents=[(alias, "note:typed-loop")],
|
|
on_document=seen.append,
|
|
)
|
|
|
|
values = fanout.sync_once()
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual(len(values), 1)
|
|
self.assertIsInstance(values[0], UiNote)
|
|
self.assertEqual(values[0].body, "from-source")
|
|
self.assertEqual(seen, [values[0]])
|
|
self.assertEqual(target.database.get_required(document, "note:typed-loop").body, "from-source")
|
|
self.assertIsInstance(target.database.get_required(alias, "note:typed-loop"), UiNote)
|
|
|
|
def test_cultmesh_snapshot_fanout_runs_background_loop(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.snapshot_background_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.snapshot_background_note.v1",
|
|
)
|
|
source = CultMesh.create_node(runtime_id="snapshot-background-source")
|
|
source.database.register_document(document)
|
|
source.database.put_raw_message(document, "note:background", {"body": "from-background"})
|
|
target = CultMesh.create_node(runtime_id="snapshot-background-target")
|
|
target.database.register_document(document)
|
|
server = CultMesh.serve_node(source)
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="snapshot-background-source",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
roles=("read-replica",),
|
|
))
|
|
seen: list[str] = []
|
|
fanout = CultMeshSnapshotFanout(
|
|
CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
|
|
target.database,
|
|
peers,
|
|
verse_id="mesh",
|
|
roles=["read-replica"],
|
|
schema_ids=["mesh.snapshot_background_note.v1"],
|
|
interval_seconds=0.05,
|
|
on_applied=lambda record: seen.append(record.record_key),
|
|
)
|
|
fanout.start()
|
|
deadline = time.monotonic() + 2.0
|
|
while not seen and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
fanout.stop()
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertTrue(seen)
|
|
self.assertEqual(target.database.get_required(document, "note:background")["body"], "from-background")
|
|
|
|
def test_cultmesh_peer_health_monitor_probes_reachable_and_failed_endpoints(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.health_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.health_note.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="health-peer")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node)
|
|
closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
closed_socket.bind(("127.0.0.1", 0))
|
|
closed_port = closed_socket.getsockname()[1]
|
|
closed_socket.close()
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="health-peer",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
roles=("read-replica",),
|
|
))
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="closed-peer",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{closed_port}",),
|
|
roles=("read-replica",),
|
|
))
|
|
seen: list[tuple[str, bool]] = []
|
|
monitor = CultMeshPeerHealthMonitor(
|
|
runtime_id="health-prober",
|
|
timeout_seconds=0.2,
|
|
on_update=lambda health: seen.append((health.peer_id, health.is_reachable)),
|
|
)
|
|
|
|
results = monitor.probe_catalog(peers, verse_id="mesh", roles=["read-replica"])
|
|
finally:
|
|
server.stop()
|
|
|
|
by_peer = {result.peer_id: result for result in results}
|
|
self.assertTrue(by_peer["health-peer"].is_reachable)
|
|
self.assertEqual(by_peer["health-peer"].runtime_id, "health-peer")
|
|
self.assertEqual(by_peer["health-peer"].runtime_kind, "python")
|
|
self.assertEqual(by_peer["health-peer"].display_name, "health-peer")
|
|
self.assertEqual(by_peer["health-peer"].supported_document_types, ("mesh.health_note",))
|
|
self.assertIn("cultnet.hello.v0", by_peer["health-peer"].supported_message_versions)
|
|
self.assertIn("cultnet.error.v0", by_peer["health-peer"].supported_message_versions)
|
|
self.assertEqual(by_peer["health-peer"].supported_mutation_contracts[0]["documentType"], "mesh.health_note")
|
|
self.assertIn("shardLog", by_peer["health-peer"].supported_mutation_contracts[0]["operations"])
|
|
self.assertFalse(by_peer["closed-peer"].is_reachable)
|
|
self.assertIsNone(by_peer["closed-peer"].runtime_kind)
|
|
self.assertEqual(by_peer["closed-peer"].supported_document_types, ())
|
|
self.assertEqual(by_peer["closed-peer"].supported_message_versions, ())
|
|
self.assertEqual(by_peer["closed-peer"].supported_mutation_contracts, ())
|
|
self.assertTrue(by_peer["closed-peer"].error)
|
|
self.assertEqual({peer_id for peer_id, _ in seen}, {"health-peer", "closed-peer"})
|
|
self.assertEqual(len(monitor.latest()), 2)
|
|
|
|
def test_cultmesh_peer_health_monitor_runs_background_probe_loop(self) -> None:
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="health-loop-peer"))
|
|
try:
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(CultMeshPeerCard(
|
|
peer_id="health-loop-peer",
|
|
verse_id="mesh",
|
|
endpoints=(f"cultnet://127.0.0.1:{server.port}",),
|
|
roles=("read-replica",),
|
|
))
|
|
seen: list[str] = []
|
|
monitor = CultMeshPeerHealthMonitor(
|
|
runtime_id="health-loop-prober",
|
|
timeout_seconds=0.2,
|
|
interval_seconds=0.05,
|
|
on_update=lambda health: seen.append(health.peer_id),
|
|
)
|
|
monitor.start(peers, verse_id="mesh", roles=["read-replica"])
|
|
deadline = time.monotonic() + 2.0
|
|
while time.monotonic() < deadline:
|
|
latest = monitor.latest()
|
|
if latest and latest[0].is_reachable:
|
|
break
|
|
time.sleep(0.01)
|
|
monitor.stop()
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertTrue(seen)
|
|
latest = monitor.latest()
|
|
self.assertEqual(latest[0].peer_id, "health-loop-peer")
|
|
self.assertTrue(latest[0].is_reachable)
|
|
|
|
def test_cultmesh_daemon_entrypoint_serves_framed_cultnet_hello(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
ready_path = Path(temp) / "ready.json"
|
|
package_src = Path(__file__).resolve().parents[1] / "src"
|
|
env = dict(os.environ)
|
|
existing_pythonpath = env.get("PYTHONPATH")
|
|
env["PYTHONPATH"] = (
|
|
str(package_src)
|
|
if not existing_pythonpath
|
|
else f"{package_src}{os.pathsep}{existing_pythonpath}"
|
|
)
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"cultmesh_py.daemon",
|
|
"--runtime-id",
|
|
"daemon-test-peer",
|
|
"--display-name",
|
|
"Daemon Test Peer",
|
|
"--port",
|
|
"0",
|
|
"--ready-file",
|
|
str(ready_path),
|
|
"--seed-interop-note",
|
|
"--seed-shard-id",
|
|
"daemon-interop",
|
|
"--verse-id",
|
|
"daemon-verse",
|
|
"--verse-display-name",
|
|
"Daemon Verse",
|
|
"--role",
|
|
"shard-primary",
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
try:
|
|
deadline = time.monotonic() + 5.0
|
|
while not ready_path.exists() and time.monotonic() < deadline:
|
|
if process.poll() is not None:
|
|
break
|
|
time.sleep(0.05)
|
|
if not ready_path.exists():
|
|
_, stderr = process.communicate(timeout=2.0)
|
|
self.fail(f"cultmesh daemon did not publish readiness: {stderr}")
|
|
ready = json.loads(ready_path.read_text(encoding="utf-8"))
|
|
self.assertEqual(ready["schemaVersion"], READY_SCHEMA_VERSION)
|
|
self.assertEqual(ready["runtimeId"], "daemon-test-peer")
|
|
self.assertEqual(ready["runtimeKind"], "python")
|
|
self.assertEqual(ready["displayName"], "Daemon Test Peer")
|
|
self.assertEqual(ready["endpoint"], f"cultnet://127.0.0.1:{ready['port']}")
|
|
self.assertEqual(ready["shardIds"], ["daemon-interop"])
|
|
self.assertEqual(ready["supportedDocumentTypes"], ["cultcache.interop-note"])
|
|
self.assertIn("cultnet.hello.v0", ready["supportedMessageVersions"])
|
|
self.assertIn("cultnet.document_put_raw.v0", ready["supportedMessageVersions"])
|
|
self.assertNotIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"])
|
|
self.assertEqual(ready["supportedMutationContracts"][0]["documentType"], "cultcache.interop-note")
|
|
self.assertIn("shardLog", ready["supportedMutationContracts"][0]["operations"])
|
|
self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": None})
|
|
self.assertEqual(ready["verses"][0]["verseId"], "daemon-verse")
|
|
self.assertEqual(ready["verses"][0]["displayName"], "Daemon Verse")
|
|
self.assertEqual(ready["verses"][0]["discoveryEndpoints"], [ready["endpoint"]])
|
|
self.assertEqual(ready["verses"][0]["authorityRuntimeIds"], ["daemon-test-peer"])
|
|
self.assertEqual(ready["peers"][0]["peerId"], "daemon-test-peer")
|
|
self.assertEqual(ready["peers"][0]["endpoints"], [ready["endpoint"]])
|
|
self.assertEqual(ready["peers"][0]["roles"], ["shard-primary"])
|
|
self.assertEqual(ready["peers"][0]["shardIds"], ["daemon-interop"])
|
|
|
|
client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
hello_response = client.request(
|
|
hello(runtime_id="daemon-test-prober"),
|
|
expected_schema_version="cultnet.hello.v0",
|
|
)
|
|
|
|
self.assertEqual(hello_response["runtimeId"], "daemon-test-peer")
|
|
self.assertEqual(hello_response["displayName"], "Daemon Test Peer")
|
|
self.assertIn("cultnet.hello.v0", hello_response["supportedMessageVersions"])
|
|
self.assertIn("cultnet.schema_catalog_request.v0", hello_response["supportedMessageVersions"])
|
|
self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
|
|
self.assertEqual(hello_response["supportedDocumentTypes"], ["cultcache.interop-note"])
|
|
self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed")
|
|
self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], int(ready["port"]))
|
|
self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"])
|
|
self.assertEqual(ready["supportedMutationContracts"], hello_response["supportedMutationContracts"])
|
|
self.assertEqual(ready["transportProfiles"], hello_response["transportProfiles"])
|
|
|
|
schema_response = client.fetch_schema_catalog(schema_ids=["cultcache.interop-note"], include_schema_json=True)
|
|
self.assertEqual(schema_response["schemas"][0]["schemaVersion"], INTEROP_SCHEMA_VERSION)
|
|
self.assertEqual(schema_response["schemas"][0]["documentType"], "cultcache.interop-note")
|
|
|
|
snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
|
|
self.assertEqual(snapshot_response.documents[0].record_key, "note:daemon-test-peer")
|
|
note = interop_note_document.decode_payload(snapshot_response.documents[0].payload)
|
|
self.assertEqual(note["authorRuntimeId"], "daemon-test-peer")
|
|
self.assertIn("daemon", note["tags"])
|
|
|
|
shard_catalog = client.fetch_shard_catalog(schema_ids=["cultcache.interop-note"])
|
|
self.assertEqual(shard_catalog["shards"][0]["shardId"], "daemon-interop")
|
|
self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["cultcache.interop-note"])
|
|
|
|
shard_log = client.fetch_shard_log_response(shard_id="daemon-interop", shard_epoch=1)
|
|
self.assertFalse(shard_log.resync_required)
|
|
self.assertEqual(shard_log.entries[0].sequence, 1)
|
|
self.assertEqual(shard_log.entries[0].change_kind, "added")
|
|
self.assertIsNotNone(shard_log.entries[0].raw_document)
|
|
assert shard_log.entries[0].raw_document is not None
|
|
self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:daemon-test-peer")
|
|
|
|
discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
|
|
self.assertEqual(verses[0].verse_id, "daemon-verse")
|
|
self.assertEqual(verses[0].display_name, "Daemon Verse")
|
|
self.assertEqual(verses[0].discovery_endpoints, (ready["endpoint"],))
|
|
self.assertEqual(verses[0].authority_runtime_ids, ("daemon-test-peer",))
|
|
self.assertEqual([verse.to_wire() for verse in verses], ready["verses"])
|
|
|
|
peers = discovery_client.fetch_peers(verse_id="daemon-verse", roles=["shard-primary"])
|
|
self.assertEqual(peers[0].peer_id, "daemon-test-peer")
|
|
self.assertEqual(peers[0].endpoints, (ready["endpoint"],))
|
|
self.assertEqual(peers[0].roles, ("shard-primary",))
|
|
self.assertEqual(peers[0].shard_ids, ("daemon-interop",))
|
|
self.assertEqual([peer.to_wire() for peer in peers], ready["peers"])
|
|
|
|
monitor = CultMeshPeerHealthMonitor(runtime_id="daemon-health-prober", timeout_seconds=2.0)
|
|
health = monitor.probe_peer(peers[0])[0]
|
|
self.assertTrue(health.is_reachable)
|
|
self.assertEqual(health.runtime_id, "daemon-test-peer")
|
|
self.assertEqual(health.supported_document_types, ("cultcache.interop-note",))
|
|
self.assertEqual(health.supported_mutation_contracts[0]["documentType"], "cultcache.interop-note")
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.communicate(timeout=5.0)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.communicate(timeout=5.0)
|
|
|
|
def test_cultmesh_daemon_restarts_with_persisted_cache_and_shard_log(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
root = Path(temp)
|
|
cache_path = root / "mesh.cc"
|
|
shard_log_path = root / "shard-log"
|
|
env = dict(os.environ)
|
|
package_src = Path(__file__).resolve().parents[1] / "src"
|
|
existing_pythonpath = env.get("PYTHONPATH")
|
|
env["PYTHONPATH"] = (
|
|
str(package_src)
|
|
if not existing_pythonpath
|
|
else f"{package_src}{os.pathsep}{existing_pythonpath}"
|
|
)
|
|
|
|
first_ready = root / "first-ready.json"
|
|
first = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"cultmesh_py.daemon",
|
|
"--runtime-id",
|
|
"durable-daemon-peer",
|
|
"--port",
|
|
"0",
|
|
"--cache-file",
|
|
str(cache_path),
|
|
"--enable-durable-shard-logs",
|
|
"--shard-log-file",
|
|
str(shard_log_path),
|
|
"--seed-interop-note",
|
|
"--seed-shard-id",
|
|
"durable-interop",
|
|
"--ready-file",
|
|
str(first_ready),
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
try:
|
|
self._wait_for_ready_file(first, first_ready)
|
|
finally:
|
|
self._terminate_process(first)
|
|
|
|
second_ready = root / "second-ready.json"
|
|
second = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"cultmesh_py.daemon",
|
|
"--runtime-id",
|
|
"durable-daemon-peer",
|
|
"--port",
|
|
"0",
|
|
"--cache-file",
|
|
str(cache_path),
|
|
"--enable-durable-shard-logs",
|
|
"--shard-log-file",
|
|
str(shard_log_path),
|
|
"--register-interop-note",
|
|
"--verse-id",
|
|
"durable-verse",
|
|
"--role",
|
|
"shard-primary",
|
|
"--ready-file",
|
|
str(second_ready),
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
try:
|
|
ready = self._wait_for_ready_file(second, second_ready)
|
|
client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
|
|
snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
|
|
self.assertEqual(snapshot_response.documents[0].record_key, "note:durable-daemon-peer")
|
|
note = interop_note_document.decode_payload(snapshot_response.documents[0].payload)
|
|
self.assertEqual(note["authorRuntimeId"], "durable-daemon-peer")
|
|
|
|
shard_log = client.fetch_shard_log_response(shard_id="durable-interop", shard_epoch=1)
|
|
self.assertFalse(shard_log.resync_required)
|
|
self.assertEqual(shard_log.entries[0].sequence, 1)
|
|
self.assertIsNotNone(shard_log.entries[0].raw_document)
|
|
assert shard_log.entries[0].raw_document is not None
|
|
self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:durable-daemon-peer")
|
|
|
|
discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
peers = discovery_client.fetch_peers(verse_id="durable-verse", roles=["shard-primary"])
|
|
self.assertEqual(peers[0].shard_ids, ("durable-interop",))
|
|
finally:
|
|
self._terminate_process(second)
|
|
|
|
def test_cultmesh_daemon_serves_live_database_subscription_mutations(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
ready_path = Path(temp) / "ready.json"
|
|
package_src = Path(__file__).resolve().parents[1] / "src"
|
|
env = dict(os.environ)
|
|
existing_pythonpath = env.get("PYTHONPATH")
|
|
env["PYTHONPATH"] = (
|
|
str(package_src)
|
|
if not existing_pythonpath
|
|
else f"{package_src}{os.pathsep}{existing_pythonpath}"
|
|
)
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"cultmesh_py.daemon",
|
|
"--runtime-id",
|
|
"live-daemon-peer",
|
|
"--port",
|
|
"0",
|
|
"--seed-interop-note",
|
|
"--seed-shard-id",
|
|
"live-interop",
|
|
"--ready-file",
|
|
str(ready_path),
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
try:
|
|
ready = self._wait_for_ready_file(process, ready_path)
|
|
client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
live_note = {
|
|
"schemaVersion": INTEROP_SCHEMA_VERSION,
|
|
"documentId": "note:daemon-live",
|
|
"authorRuntimeId": "daemon-live-client",
|
|
"title": "daemon live subscription note",
|
|
"body": "live subscription put from Python daemon smoke",
|
|
"tags": ["daemon", "subscription", "interop"],
|
|
}
|
|
|
|
with client.subscribe_database(
|
|
subscription_id="daemon-live-sub",
|
|
schema_ids=["cultcache.interop-note"],
|
|
) as subscription:
|
|
snapshot = subscription.read_next_snapshot_response()
|
|
subscription.send(document_put_raw(
|
|
message_id="daemon-live-put",
|
|
key="note:daemon-live",
|
|
schema_id="cultcache.interop-note",
|
|
stored_at="2026-06-14T00:00:00Z",
|
|
payload=interop_note_document.encode_payload(live_note),
|
|
source_runtime_id="daemon-live-client",
|
|
shard_id="live-interop",
|
|
shard_epoch=1,
|
|
))
|
|
change = subscription.read_next_change()
|
|
|
|
self.assertEqual(snapshot.documents[0].record_key, "note:live-daemon-peer")
|
|
self.assertEqual(change.change_kind, "added")
|
|
self.assertEqual(change.record_key, "note:daemon-live")
|
|
self.assertIsNotNone(change.raw_document)
|
|
assert change.raw_document is not None
|
|
self.assertEqual(
|
|
interop_note_document.decode_payload(change.raw_document.payload),
|
|
live_note,
|
|
)
|
|
|
|
shard_log = client.fetch_shard_log_response(shard_id="live-interop", shard_epoch=1)
|
|
self.assertFalse(shard_log.resync_required)
|
|
self.assertEqual([entry.sequence for entry in shard_log.entries], [1, 2])
|
|
self.assertEqual(shard_log.entries[1].change_kind, "added")
|
|
self.assertIsNotNone(shard_log.entries[1].raw_document)
|
|
assert shard_log.entries[1].raw_document is not None
|
|
self.assertEqual(shard_log.entries[1].raw_document.record_key, "note:daemon-live")
|
|
finally:
|
|
self._terminate_process(process)
|
|
|
|
def test_cultmesh_daemon_serves_compacted_shard_log_resync_boundary(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
root = Path(temp)
|
|
cache_path = root / "mesh.cc"
|
|
shard_log_path = root / "shard-log"
|
|
store = CultNetFileShardMutationLogStore(shard_log_path)
|
|
node = CultMesh.create_node(
|
|
cache_path,
|
|
runtime_id="compacted-daemon-peer",
|
|
enable_durable_shard_logs=True,
|
|
shard_log_path=shard_log_path,
|
|
)
|
|
node.database.register_document(interop_note_document)
|
|
node.database.put_raw_message(
|
|
interop_note_document,
|
|
"note:compacted-one",
|
|
{
|
|
"schemaVersion": INTEROP_SCHEMA_VERSION,
|
|
"documentId": "note:compacted-one",
|
|
"authorRuntimeId": "compacted-daemon-peer",
|
|
"title": "compacted daemon note one",
|
|
"body": "first retained cache value",
|
|
"tags": ["daemon", "compaction"],
|
|
},
|
|
message_id="compacted-put-1",
|
|
shard_id="compacted-interop",
|
|
shard_epoch=7,
|
|
)
|
|
node.database.put_raw_message(
|
|
interop_note_document,
|
|
"note:compacted-two",
|
|
{
|
|
"schemaVersion": INTEROP_SCHEMA_VERSION,
|
|
"documentId": "note:compacted-two",
|
|
"authorRuntimeId": "compacted-daemon-peer",
|
|
"title": "compacted daemon note two",
|
|
"body": "second retained cache value",
|
|
"tags": ["daemon", "compaction"],
|
|
},
|
|
message_id="compacted-put-2",
|
|
shard_id="compacted-interop",
|
|
shard_epoch=7,
|
|
)
|
|
store.compact_through("compacted-interop", 1)
|
|
|
|
ready_path = root / "ready.json"
|
|
package_src = Path(__file__).resolve().parents[1] / "src"
|
|
env = dict(os.environ)
|
|
existing_pythonpath = env.get("PYTHONPATH")
|
|
env["PYTHONPATH"] = (
|
|
str(package_src)
|
|
if not existing_pythonpath
|
|
else f"{package_src}{os.pathsep}{existing_pythonpath}"
|
|
)
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"cultmesh_py.daemon",
|
|
"--runtime-id",
|
|
"compacted-daemon-peer",
|
|
"--port",
|
|
"0",
|
|
"--cache-file",
|
|
str(cache_path),
|
|
"--enable-durable-shard-logs",
|
|
"--shard-log-file",
|
|
str(shard_log_path),
|
|
"--register-interop-note",
|
|
"--ready-file",
|
|
str(ready_path),
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
try:
|
|
ready = self._wait_for_ready_file(process, ready_path)
|
|
client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
|
|
snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
|
|
self.assertEqual(
|
|
{document.record_key for document in snapshot_response.documents},
|
|
{"note:compacted-one", "note:compacted-two"},
|
|
)
|
|
|
|
compacted = client.fetch_shard_log_response(
|
|
shard_id="compacted-interop",
|
|
shard_epoch=7,
|
|
after_sequence=0,
|
|
)
|
|
retained = client.fetch_shard_log_response(
|
|
shard_id="compacted-interop",
|
|
shard_epoch=7,
|
|
after_sequence=1,
|
|
)
|
|
|
|
self.assertTrue(compacted.resync_required)
|
|
self.assertEqual(compacted.reason, "compacted_history")
|
|
self.assertEqual(compacted.compacted_through, 1)
|
|
self.assertEqual(compacted.entries, ())
|
|
self.assertFalse(retained.resync_required)
|
|
self.assertEqual([entry.sequence for entry in retained.entries], [2])
|
|
self.assertIsNotNone(retained.entries[0].raw_document)
|
|
assert retained.entries[0].raw_document is not None
|
|
self.assertEqual(retained.entries[0].raw_document.record_key, "note:compacted-two")
|
|
finally:
|
|
self._terminate_process(process)
|
|
|
|
def test_cultmesh_daemon_accepts_schema_write_forwarder_mutations(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
ready_path = Path(temp) / "ready.json"
|
|
package_src = Path(__file__).resolve().parents[1] / "src"
|
|
env = dict(os.environ)
|
|
existing_pythonpath = env.get("PYTHONPATH")
|
|
env["PYTHONPATH"] = (
|
|
str(package_src)
|
|
if not existing_pythonpath
|
|
else f"{package_src}{os.pathsep}{existing_pythonpath}"
|
|
)
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"cultmesh_py.daemon",
|
|
"--runtime-id",
|
|
"forwarder-daemon-peer",
|
|
"--port",
|
|
"0",
|
|
"--register-interop-note",
|
|
"--ready-file",
|
|
str(ready_path),
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
try:
|
|
ready = self._wait_for_ready_file(process, ready_path)
|
|
endpoint = f"cultnet://127.0.0.1:{ready['port']}"
|
|
shard = CultNetShardDescriptor(
|
|
shard_id="forwarded-interop",
|
|
owner_runtime_id="forwarder-daemon-peer",
|
|
epoch=3,
|
|
is_primary=False,
|
|
schema_ids=("cultcache.interop-note",),
|
|
primary_endpoints=(endpoint,),
|
|
)
|
|
forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0)
|
|
note = {
|
|
"schemaVersion": INTEROP_SCHEMA_VERSION,
|
|
"documentId": "note:forwarded-daemon",
|
|
"authorRuntimeId": "forwarding-client",
|
|
"title": "forwarded daemon note",
|
|
"body": "forwarded through the schema write-forwarder",
|
|
"tags": ["daemon", "forwarded"],
|
|
}
|
|
|
|
forwarder.forward_put(shard, document_put_raw(
|
|
message_id="forwarded-daemon-put",
|
|
key="note:forwarded-daemon",
|
|
schema_id="cultcache.interop-note",
|
|
stored_at="2026-06-14T00:00:00Z",
|
|
payload=interop_note_document.encode_payload(note),
|
|
source_runtime_id="forwarding-client",
|
|
).to_wire())
|
|
|
|
client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
self._eventually(lambda: bool(client.fetch_snapshot_response(
|
|
schema_ids=["cultcache.interop-note"],
|
|
record_keys=["note:forwarded-daemon"],
|
|
).documents))
|
|
snapshot = client.fetch_snapshot_response(
|
|
schema_ids=["cultcache.interop-note"],
|
|
record_keys=["note:forwarded-daemon"],
|
|
)
|
|
self.assertEqual(
|
|
interop_note_document.decode_payload(snapshot.documents[0].payload),
|
|
note,
|
|
)
|
|
|
|
forwarder.forward_delete(shard, document_delete(
|
|
message_id="forwarded-daemon-delete",
|
|
schema_id="cultcache.interop-note",
|
|
record_key="note:forwarded-daemon",
|
|
).to_wire())
|
|
self._eventually(lambda: not client.fetch_snapshot_response(
|
|
schema_ids=["cultcache.interop-note"],
|
|
record_keys=["note:forwarded-daemon"],
|
|
).documents)
|
|
|
|
shard_log = client.fetch_shard_log_response(shard_id="forwarded-interop", shard_epoch=3)
|
|
self.assertEqual([entry.change_kind for entry in shard_log.entries], ["added", "removed"])
|
|
self.assertEqual(shard_log.entries[0].put["shardId"], "forwarded-interop")
|
|
self.assertEqual(shard_log.entries[0].put["shardEpoch"], 3)
|
|
self.assertEqual(shard_log.entries[1].delete["shardId"], "forwarded-interop")
|
|
self.assertEqual(shard_log.entries[1].delete["shardEpoch"], 3)
|
|
finally:
|
|
self._terminate_process(process)
|
|
|
|
def test_cultmesh_daemon_refuses_a_snapshot_document_limit_of_zero_at_startup(self) -> None:
|
|
# A limit of zero is a server that could answer no snapshot, which the Rust document
|
|
# server's options also refuse; the daemon passes the flag to the server, and exits with
|
|
# a usage error naming the rule instead of serving. The limit's enforcement as a peer error is pinned
|
|
# in process by test_cultmesh_local_server_rejects_oversized_snapshot_responses.
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
ready_path = Path(temp) / "ready.json"
|
|
package_src = Path(__file__).resolve().parents[1] / "src"
|
|
env = dict(os.environ)
|
|
existing_pythonpath = env.get("PYTHONPATH")
|
|
env["PYTHONPATH"] = (
|
|
str(package_src)
|
|
if not existing_pythonpath
|
|
else f"{package_src}{os.pathsep}{existing_pythonpath}"
|
|
)
|
|
completed = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"cultmesh_py.daemon",
|
|
"--runtime-id",
|
|
"limited-daemon-peer",
|
|
"--port",
|
|
"0",
|
|
"--seed-interop-note",
|
|
"--max-snapshot-documents",
|
|
"0",
|
|
"--ready-file",
|
|
str(ready_path),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
timeout=30,
|
|
)
|
|
# A usage error from argparse: exit status 2, the rule named, no traceback.
|
|
self.assertEqual(completed.returncode, 2, completed.stderr)
|
|
self.assertIn("error: max_snapshot_documents must be greater than zero", completed.stderr)
|
|
self.assertNotIn("Traceback", completed.stderr)
|
|
self.assertFalse(ready_path.exists())
|
|
# A port no socket can bind is a usage error too.
|
|
completed = subprocess.run(
|
|
[sys.executable, "-m", "cultmesh_py.daemon", "--port", "99999"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
timeout=30,
|
|
)
|
|
self.assertEqual(completed.returncode, 2, completed.stderr)
|
|
self.assertIn("port must be between 0 and 65535", completed.stderr)
|
|
self.assertNotIn("Traceback", completed.stderr)
|
|
|
|
def test_cultmesh_daemon_serves_opt_in_simulation_observations(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
ready_path = Path(temp) / "ready.json"
|
|
package_src = Path(__file__).resolve().parents[1] / "src"
|
|
env = dict(os.environ)
|
|
existing_pythonpath = env.get("PYTHONPATH")
|
|
env["PYTHONPATH"] = (
|
|
str(package_src)
|
|
if not existing_pythonpath
|
|
else f"{package_src}{os.pathsep}{existing_pythonpath}"
|
|
)
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"cultmesh_py.daemon",
|
|
"--runtime-id",
|
|
"simulation-daemon-peer",
|
|
"--port",
|
|
"0",
|
|
"--enable-simulation-observations",
|
|
"--simulation-minimum-witnesses",
|
|
"2",
|
|
"--simulation-quorum-ratio",
|
|
"1.0",
|
|
"--verse-id",
|
|
"simulation-verse",
|
|
"--ready-file",
|
|
str(ready_path),
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
try:
|
|
ready = self._wait_for_ready_file(process, ready_path)
|
|
self.assertIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"])
|
|
self.assertIn("cultnet.simulation_consensus_candidate.v0", ready["supportedMessageVersions"])
|
|
client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
hello_response = client.request(
|
|
hello(runtime_id="simulation-daemon-prober"),
|
|
expected_schema_version="cultnet.hello.v0",
|
|
)
|
|
self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"])
|
|
discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
|
|
peers = discovery_client.fetch_peers(verse_id="simulation-verse", roles=["simulation-observer"])
|
|
self.assertEqual(peers[0].peer_id, "simulation-daemon-peer")
|
|
self.assertIn("simulation-observer", peers[0].roles)
|
|
self.assertIn("read-replica", peers[0].roles)
|
|
|
|
claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
|
|
first = client.request(
|
|
simulation_observation(
|
|
message_id="daemon-sim-1",
|
|
witness_runtime_id="watcher-1",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice shot bob first",
|
|
),
|
|
expected_schema_version="cultnet.simulation_consensus_candidate.v0",
|
|
)
|
|
second = client.request(
|
|
simulation_observation(
|
|
message_id="daemon-sim-2",
|
|
witness_runtime_id="watcher-2",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice shot bob first",
|
|
),
|
|
expected_schema_version="cultnet.simulation_consensus_candidate.v0",
|
|
)
|
|
|
|
self.assertEqual(first["messageId"], "daemon-sim-1")
|
|
self.assertFalse(first["hasQuorum"])
|
|
self.assertEqual(first["witnessCount"], 1)
|
|
self.assertEqual(second["messageId"], "daemon-sim-2")
|
|
self.assertTrue(second["hasQuorum"])
|
|
self.assertEqual(second["witnessCount"], 2)
|
|
self.assertEqual(second["claimHash"], claim_hash)
|
|
finally:
|
|
self._terminate_process(process)
|
|
|
|
def _wait_for_ready_file(self, process: subprocess.Popen[str], ready_path: Path) -> dict[str, object]:
|
|
deadline = time.monotonic() + 5.0
|
|
while not ready_path.exists() and time.monotonic() < deadline:
|
|
if process.poll() is not None:
|
|
break
|
|
time.sleep(0.05)
|
|
if not ready_path.exists():
|
|
_, stderr = process.communicate(timeout=2.0)
|
|
self.fail(f"cultmesh daemon did not publish readiness: {stderr}")
|
|
return json.loads(ready_path.read_text(encoding="utf-8"))
|
|
|
|
def _terminate_process(self, process: subprocess.Popen[str]) -> None:
|
|
process.terminate()
|
|
try:
|
|
process.communicate(timeout=5.0)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.communicate(timeout=5.0)
|
|
|
|
def test_cultmesh_local_server_serves_node_and_catalogs_over_clients(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.server_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.server_note.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-server")
|
|
node.database.register_document(document)
|
|
node.database.put_raw_message(document, "note:1", {"body": "served"}, shard_id="notes", shard_epoch=1)
|
|
observation_hub = CultNetSimulationObservationHub(
|
|
CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0)
|
|
)
|
|
seen_candidates: list[CultNetSimulationConsensusCandidate] = []
|
|
unsubscribe_candidates = observation_hub.watch_candidates(seen_candidates.append)
|
|
claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
|
|
verses = CultMeshVerseCatalog()
|
|
verses.upsert(
|
|
CultMeshVerseDescriptor(
|
|
verse_id="server-verse",
|
|
display_name="Server Verse",
|
|
authority_model="local",
|
|
compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
|
)
|
|
)
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(
|
|
CultMeshPeerCard(
|
|
peer_id="mesh-server",
|
|
verse_id="server-verse",
|
|
endpoints=("cultnet://127.0.0.1:0",),
|
|
roles=("read-replica",),
|
|
)
|
|
)
|
|
|
|
server = CultMesh.serve_node(
|
|
node,
|
|
verse_catalog=verses,
|
|
peer_catalog=peers,
|
|
observation_hub=observation_hub,
|
|
display_name="Mesh Server",
|
|
)
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
hello_response = raw_client.request(hello(runtime_id="probe"), expected_schema_version="cultnet.hello.v0")
|
|
schema_response = raw_client.fetch_schema_catalog(schema_ids=["mesh.server_note.v1"], include_schema_json=True)
|
|
rudp_client = CultMesh.create_client(
|
|
endpoint=f"rudp://127.0.0.1:{server.port}",
|
|
timeout_seconds=2.0,
|
|
connection_id=server.rudp_connection_id,
|
|
runtime_id="rudp-probe",
|
|
)
|
|
rudp_hello_response = rudp_client.request(
|
|
hello(runtime_id="rudp-probe"),
|
|
expected_schema_version="cultnet.hello.v0",
|
|
)
|
|
rudp_schema_response = rudp_client.fetch_schema_catalog(
|
|
schema_ids=["mesh.server_note.v1"],
|
|
include_schema_json=True,
|
|
)
|
|
wire_schema_response = raw_client.fetch_schema_catalog(kinds=["wire_message"], include_schema_json=True)
|
|
synced_schema_catalog = CultNetSchemaCatalog()
|
|
synced_wire_descriptors = raw_client.sync_schema_catalog(
|
|
synced_schema_catalog,
|
|
kinds=["wire_message"],
|
|
include_schema_json=True,
|
|
)
|
|
snapshot_response = raw_client.fetch_snapshot(schema_ids=["mesh.server_note.v1"])
|
|
typed_snapshot_response = raw_client.fetch_snapshot_response(schema_ids=["mesh.server_note.v1"])
|
|
shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.server_note.v1"])
|
|
synced_shard_catalog = CultNetShardCatalog()
|
|
synced_shards = raw_client.sync_shard_catalog(synced_shard_catalog, schema_ids=["mesh.server_note.v1"])
|
|
shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=1)
|
|
typed_shard_log = raw_client.fetch_shard_log_response(shard_id="notes", shard_epoch=1)
|
|
self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed")
|
|
self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], server.port)
|
|
stale_shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=0)
|
|
discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
fetched_verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
|
|
fetched_peers = discovery_client.fetch_peers(verse_id="server-verse", roles=["read-replica"])
|
|
first_candidate = raw_client.request(
|
|
simulation_observation(
|
|
message_id="obs-1",
|
|
witness_runtime_id="watcher-1",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice shot bob first",
|
|
),
|
|
expected_schema_version="cultnet.simulation_consensus_candidate.v0",
|
|
)
|
|
quorum_candidate = raw_client.request(
|
|
simulation_observation(
|
|
message_id="obs-2",
|
|
witness_runtime_id="watcher-2",
|
|
shard_id="arena",
|
|
shard_epoch=4,
|
|
frame=100,
|
|
subject_id="bob",
|
|
claim_kind="hit",
|
|
claim_hash=claim_hash,
|
|
claim_summary="alice shot bob first",
|
|
),
|
|
expected_schema_version="cultnet.simulation_consensus_candidate.v0",
|
|
)
|
|
with raw_client.subscribe_database(subscription_id="sub-server", schema_ids=["mesh.server_note.v1"]) as subscription:
|
|
subscription_snapshot = subscription.read_next_snapshot_response()
|
|
subscription.send(document_put_raw(
|
|
message_id="server-put",
|
|
key="note:2",
|
|
schema_id="mesh.server_note.v1",
|
|
stored_at="2026-06-14T00:00:00Z",
|
|
payload=document.encode_payload({"body": "subscribed"}),
|
|
shard_id="primary",
|
|
shard_epoch=1,
|
|
))
|
|
subscription_change = subscription.read_next_change()
|
|
subscription.send(document_delete(
|
|
message_id="server-delete",
|
|
schema_id="mesh.server_note.v1",
|
|
record_key="note:2",
|
|
shard_id="primary",
|
|
shard_epoch=1,
|
|
))
|
|
subscription_delete = subscription.read_next_change()
|
|
finally:
|
|
unsubscribe_candidates()
|
|
server.stop()
|
|
|
|
self.assertEqual(hello_response["runtimeId"], "mesh-server")
|
|
self.assertEqual(hello_response["displayName"], "Mesh Server")
|
|
self.assertIn("cultnet.database_subscribe.v0", hello_response["supportedMessageVersions"])
|
|
self.assertIn("cultnet.error.v0", hello_response["supportedMessageVersions"])
|
|
self.assertIn("cultnet.document_put_raw.v0", hello_response["supportedMessageVersions"])
|
|
self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
|
|
self.assertEqual(hello_response["supportedMutationContracts"][0]["documentType"], "mesh.server_note")
|
|
self.assertIn("documentDelete", hello_response["supportedMutationContracts"][0]["operations"])
|
|
self.assertIn("shardLog", hello_response["supportedMutationContracts"][0]["operations"])
|
|
advertised_transports = {
|
|
transport["protocol"]: transport
|
|
for profile in hello_response["transportProfiles"]
|
|
for transport in profile["transports"]
|
|
}
|
|
self.assertEqual(advertised_transports["tcp_framed"]["port"], server.port)
|
|
self.assertEqual(advertised_transports["rudp"]["port"], server.port)
|
|
self.assertEqual(rudp_hello_response["runtimeId"], "mesh-server")
|
|
self.assertEqual(rudp_schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1")
|
|
self.assertEqual(schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1")
|
|
self.assertIn("schemaJson", schema_response["schemas"][0])
|
|
self.assertIn("cultnet.document_delete.v0", schema_response["schemas"][0]["wireContracts"])
|
|
self.assertIn("cultnet.shard_log_response.v0", schema_response["schemas"][0]["wireContracts"])
|
|
wire_descriptors = {schema["schemaVersion"]: schema for schema in wire_schema_response["schemas"]}
|
|
self.assertEqual(wire_descriptors["cultnet.error.v0"]["kind"], "wire_message")
|
|
self.assertEqual(wire_descriptors["cultnet.document_put_raw.v0"]["kind"], "wire_message")
|
|
self.assertIn("schemaJson", wire_descriptors["cultnet.document_put_raw.v0"])
|
|
self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors)
|
|
self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
|
|
self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
|
|
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json"))
|
|
self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1")
|
|
self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1")
|
|
self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1")
|
|
self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["mesh.server_note.v1"])
|
|
self.assertEqual(shard_catalog["shards"][0]["epoch"], 1)
|
|
self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes")
|
|
self.assertEqual(synced_shards[0].shard_id, "notes")
|
|
self.assertEqual(synced_shards[0].epoch, 1)
|
|
self.assertEqual(synced_shard_catalog.get("notes"), synced_shards[0])
|
|
self.assertEqual(shard_log["entries"][0]["changeKind"], "added")
|
|
self.assertTrue(stale_shard_log["resyncRequired"])
|
|
self.assertEqual(stale_shard_log["reason"], "stale_epoch")
|
|
self.assertEqual(stale_shard_log["shardEpoch"], 1)
|
|
self.assertEqual(stale_shard_log["entries"], [])
|
|
self.assertEqual(typed_shard_log.last_sequence, 1)
|
|
self.assertEqual(typed_shard_log.entries[0].put["document"]["recordKey"], "note:1")
|
|
self.assertIsInstance(typed_shard_log.entries[0].raw_document, CultNetRawDocumentRecord)
|
|
self.assertEqual(typed_shard_log.entries[0].raw_document.record_key, "note:1")
|
|
self.assertEqual(fetched_verses[0].verse_id, "server-verse")
|
|
self.assertEqual(fetched_peers[0].peer_id, "mesh-server")
|
|
self.assertEqual(first_candidate["messageId"], "obs-1")
|
|
self.assertFalse(first_candidate["hasQuorum"])
|
|
self.assertEqual(quorum_candidate["messageId"], "obs-2")
|
|
self.assertEqual(quorum_candidate["claimHash"], claim_hash)
|
|
self.assertTrue(quorum_candidate["hasQuorum"])
|
|
self.assertEqual(quorum_candidate["witnessCount"], 2)
|
|
self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash])
|
|
self.assertIsInstance(subscription_snapshot, CultNetRawSnapshotResponse)
|
|
self.assertEqual(subscription_snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0")
|
|
self.assertEqual(subscription_change.change_kind, "added")
|
|
self.assertEqual(subscription_change.record_key, "note:2")
|
|
self.assertEqual(subscription_change.document["recordKey"], "note:2")
|
|
self.assertIsInstance(subscription_change.raw_document, CultNetRawDocumentRecord)
|
|
self.assertEqual(subscription_change.raw_document.schema_id, "mesh.server_note.v1")
|
|
self.assertEqual(subscription_delete.change_kind, "removed")
|
|
self.assertEqual(subscription_delete.record_key, "note:2")
|
|
self.assertIsNone(subscription_delete.raw_document)
|
|
self.assertIsNone(node.database.get(document, "note:2"))
|
|
|
|
def test_cultmesh_local_server_resolves_snapshot_and_shard_catalog_schema_aliases(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.alias_server_note",
|
|
[("body", 0)],
|
|
schema_id="sha256:mesh-alias-server-note",
|
|
schema_name="mesh.alias_server_note",
|
|
schema_version="mesh.alias_server_note.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-alias-server")
|
|
node.database.register_document(document)
|
|
node.database.put_raw_message(
|
|
document,
|
|
"note:alias",
|
|
{"body": "served through alias"},
|
|
shard_id="notes",
|
|
shard_epoch=1,
|
|
)
|
|
|
|
server = CultMesh.serve_node(node)
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
snapshot = raw_client.fetch_snapshot(schema_ids=["mesh.alias_server_note.v1"])
|
|
typed_snapshot = raw_client.fetch_snapshot_response(schema_ids=["mesh.alias_server_note.v1"])
|
|
shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.alias_server_note.v1"])
|
|
finally:
|
|
server.stop()
|
|
|
|
local_schema_id = document.catalog_entry().schema_id
|
|
self.assertEqual(snapshot["documents"][0]["schemaId"], local_schema_id)
|
|
self.assertEqual(snapshot["documents"][0]["recordKey"], "note:alias")
|
|
self.assertEqual(typed_snapshot.documents[0].schema_id, local_schema_id)
|
|
self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id])
|
|
self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes")
|
|
|
|
def test_cultmesh_shard_snapshot_keeps_foreign_schema_alias_membership(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.alias_shard_note",
|
|
[
|
|
("schema_version", 0),
|
|
("body", 1),
|
|
],
|
|
schema_id="sha256:mesh-alias-shard-note",
|
|
schema_name="mesh.alias_shard_note",
|
|
schema_version="mesh.alias_shard_note.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-alias-shard-server")
|
|
node.database.register_document(document)
|
|
|
|
node.database.apply_raw_put_message(document_put_raw(
|
|
message_id="put-foreign-shard-alias",
|
|
key="note:foreign-shard",
|
|
schema_id="runtime.generated.mesh.alias_shard_note.ui.99",
|
|
stored_at="2026-06-27T00:00:00Z",
|
|
payload=document.encode_payload({
|
|
"schema_version": "mesh.alias_shard_note.v1",
|
|
"body": "visible through shard snapshot",
|
|
}),
|
|
shard_id="notes",
|
|
shard_epoch=1,
|
|
).to_wire())
|
|
node.database.put_raw_message(
|
|
document,
|
|
"note:other-shard",
|
|
{
|
|
"schema_version": "mesh.alias_shard_note.v1",
|
|
"body": "hidden by shard filter",
|
|
},
|
|
shard_id="other",
|
|
shard_epoch=1,
|
|
)
|
|
|
|
response = node.database.build_snapshot_response(
|
|
schema_ids=["mesh.alias_shard_note.v1"],
|
|
shard_id="notes",
|
|
shard_epoch=1,
|
|
)
|
|
|
|
local_schema_id = document.catalog_entry().schema_id
|
|
self.assertEqual(response.shard_id, "notes")
|
|
self.assertEqual(response.shard_epoch, 1)
|
|
self.assertEqual([(record.schema_id, record.record_key) for record in response.documents], [
|
|
(local_schema_id, "note:foreign-shard"),
|
|
])
|
|
self.assertEqual(node.cache.get_required_envelope(document, "note:foreign-shard").schema_id, local_schema_id)
|
|
|
|
node.database.apply_raw_delete_message(document_delete(
|
|
message_id="delete-foreign-shard-alias",
|
|
schema_id="mesh.alias_shard_note.v1",
|
|
record_key="note:foreign-shard",
|
|
shard_id="notes",
|
|
shard_epoch=1,
|
|
).to_wire())
|
|
deleted_response = node.database.build_snapshot_response(
|
|
schema_ids=["mesh.alias_shard_note.v1"],
|
|
shard_id="notes",
|
|
shard_epoch=1,
|
|
)
|
|
self.assertEqual(deleted_response.documents, ())
|
|
|
|
def test_cultmesh_local_server_notifies_schema_alias_subscription_for_raw_put(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.alias_sub_note",
|
|
[
|
|
("schema_version", 0),
|
|
("body", 1),
|
|
],
|
|
schema_id="sha256:mesh-alias-sub-note",
|
|
schema_name="mesh.alias_sub_note",
|
|
schema_version="mesh.alias_sub_note.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-alias-sub-server")
|
|
node.database.register_document(document)
|
|
|
|
server = CultMesh.serve_node(node)
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
with raw_client.subscribe_database(
|
|
subscription_id="sub-alias",
|
|
schema_ids=["mesh.alias_sub_note.v1"],
|
|
include_snapshot=False,
|
|
) as subscription:
|
|
subscription.send(document_put_raw(
|
|
message_id="put-foreign-alias",
|
|
key="note:alias-sub",
|
|
schema_id="runtime.generated.mesh.alias_sub_note.ui.99",
|
|
stored_at="2026-06-27T00:00:00Z",
|
|
payload=document.encode_payload({
|
|
"schema_version": "mesh.alias_sub_note.v1",
|
|
"body": "notified",
|
|
}),
|
|
))
|
|
change = subscription.read_next_change()
|
|
finally:
|
|
server.stop()
|
|
|
|
local_schema_id = document.catalog_entry().schema_id
|
|
self.assertEqual(change.subscription_id, "sub-alias")
|
|
self.assertEqual(change.change_kind, "added")
|
|
self.assertEqual(change.record_key, "note:alias-sub")
|
|
self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord)
|
|
self.assertEqual(change.raw_document.schema_id, "runtime.generated.mesh.alias_sub_note.ui.99")
|
|
self.assertEqual(node.database.get_required(document, "note:alias-sub")["body"], "notified")
|
|
self.assertEqual(node.cache.get_required_envelope(document, "note:alias-sub").schema_id, local_schema_id)
|
|
|
|
def test_cultmesh_local_server_notifies_schema_alias_subscription_for_delete(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.alias_delete_note",
|
|
[("body", 0)],
|
|
schema_id="sha256:mesh-alias-delete-note",
|
|
schema_name="mesh.alias_delete_note",
|
|
schema_version="mesh.alias_delete_note.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-alias-delete-server")
|
|
node.database.register_document(document)
|
|
node.database.put_raw_message(
|
|
document,
|
|
"note:delete-alias",
|
|
{"body": "remove me"},
|
|
shard_id="notes",
|
|
shard_epoch=1,
|
|
)
|
|
|
|
server = CultMesh.serve_node(node)
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
with raw_client.subscribe_database(
|
|
subscription_id="sub-delete-alias",
|
|
schema_ids=[document.catalog_entry().schema_id],
|
|
include_snapshot=False,
|
|
) as subscription:
|
|
subscription.send(document_delete(
|
|
message_id="delete-foreign-alias",
|
|
schema_id="mesh.alias_delete_note.v1",
|
|
record_key="note:delete-alias",
|
|
shard_id="notes",
|
|
shard_epoch=1,
|
|
))
|
|
change = subscription.read_next_change()
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual(change.subscription_id, "sub-delete-alias")
|
|
self.assertEqual(change.change_kind, "removed")
|
|
self.assertEqual(change.schema_id, "mesh.alias_delete_note.v1")
|
|
self.assertEqual(change.record_key, "note:delete-alias")
|
|
self.assertIsNone(change.raw_document)
|
|
self.assertIsNone(node.database.get(document, "note:delete-alias"))
|
|
|
|
def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.snapshot_limit_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.snapshot_limit_note.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-snapshot-limits")
|
|
node.database.register_document(document)
|
|
node.database.put_raw_message(document, "note:1", {"body": "first"})
|
|
node.database.put_raw_message(document, "note:2", {"body": "second"})
|
|
|
|
server = CultMesh.serve_node(node, max_snapshot_documents=1)
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
filtered_snapshot = raw_client.fetch_snapshot(record_keys=["note:1"])
|
|
error = raw_client.request(
|
|
snapshot_request(schema_ids=["mesh.snapshot_limit_note.v1"]),
|
|
expected_schema_version="cultnet.error.v0",
|
|
)
|
|
with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded"):
|
|
raw_client.fetch_snapshot(schema_ids=["mesh.snapshot_limit_note.v1"])
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual(filtered_snapshot["schemaVersion"], "cultnet.snapshot_response_raw.v0")
|
|
self.assertEqual([document["recordKey"] for document in filtered_snapshot["documents"]], ["note:1"])
|
|
self.assertEqual(error["schemaVersion"], "cultnet.error.v0")
|
|
self.assertIn("Snapshot document limit exceeded", error["error"])
|
|
self.assertEqual(error["code"], "snapshot_document_limit_exceeded")
|
|
self.assertEqual(error["details"]["documentCount"], 2)
|
|
self.assertEqual(error["details"]["maxSnapshotDocuments"], 1)
|
|
|
|
def test_cultmesh_local_server_refuses_a_put_it_could_never_serve(self) -> None:
|
|
# A put is admitted only if some snapshot request could return it. The put arrives under a
|
|
# long foreign schema alias and is stored and served under the shorter local schema id, so
|
|
# the size that decides is the served one, not the put's own.
|
|
document = define_database_entry_type(
|
|
"mesh.served_bound_note",
|
|
[("schema_version", 0), ("body", 1)],
|
|
schema_id="sha256:mesh-served-bound",
|
|
schema_name="mesh.served_bound_note",
|
|
schema_version="mesh.served_bound_note.v1",
|
|
)
|
|
|
|
def put(message_id: str, key: str, body_length: int) -> dict[str, Any]:
|
|
return document_put_raw(
|
|
message_id=message_id,
|
|
key=key,
|
|
schema_id="runtime.generated.mesh.served_bound_note.ui.99",
|
|
stored_at="2026-09-30T00:00:00Z",
|
|
payload=document.encode_payload({
|
|
"schema_version": "mesh.served_bound_note.v1",
|
|
"body": "x" * body_length,
|
|
}),
|
|
).to_wire()
|
|
|
|
def exchange(server: Any, *messages: dict[str, Any]) -> tuple[dict[str, Any], bytes]:
|
|
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
with client.open_transport() as transport:
|
|
for message in messages:
|
|
transport.send("schema", msgpack.packb(message, use_bin_type=True))
|
|
payload = transport.receive().payload
|
|
return msgpack.unpackb(payload, raw=False), payload
|
|
|
|
empty_id_snapshot = snapshot_request(message_id="").to_wire()
|
|
|
|
# The bound is what the server actually serves for the at-bound document alone, under the
|
|
# shortest message id it answers (an empty one), measured on an unbounded server.
|
|
def served_alone_bytes(key: str, body_length: int) -> int:
|
|
node = CultMesh.create_node(runtime_id="mesh-served-bound-probe")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node, enable_rudp=False)
|
|
try:
|
|
response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot)
|
|
finally:
|
|
server.stop()
|
|
self.assertEqual(response["schemaVersion"], "cultnet.snapshot_response_raw.v0")
|
|
self.assertEqual(len(response["documents"]), 1)
|
|
return len(payload)
|
|
|
|
limit = served_alone_bytes("note:fits", 100)
|
|
self.assertEqual(served_alone_bytes("note:over", 101), limit + 1, "fixture: the documents must straddle the bound by one byte")
|
|
|
|
node = CultMesh.create_node(runtime_id="mesh-served-bound")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node, max_snapshot_bytes=limit, enable_rudp=False)
|
|
try:
|
|
refused, _ = exchange(server, put("put-over", "note:over", 101))
|
|
stored_after_refusal = node.database.get(document, "note:over")
|
|
served, served_payload = exchange(server, put("put-at", "note:fits", 100), empty_id_snapshot)
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual(refused["schemaVersion"], "cultnet.error.v0")
|
|
self.assertEqual(refused["messageId"], "put-over")
|
|
self.assertEqual(refused["code"], "document_unservable")
|
|
self.assertEqual(refused["details"], {"responseBytes": limit + 1, "maxSnapshotBytes": limit})
|
|
self.assertIn(str(limit + 1), refused["error"])
|
|
self.assertIn(str(limit), refused["error"])
|
|
self.assertIsNone(stored_after_refusal)
|
|
|
|
self.assertEqual(served["schemaVersion"], "cultnet.snapshot_response_raw.v0")
|
|
self.assertEqual([record["recordKey"] for record in served["documents"]], ["note:fits"])
|
|
self.assertEqual(served["documents"][0]["schemaId"], document.catalog_entry().schema_id)
|
|
self.assertEqual(len(served_payload), limit)
|
|
|
|
def _rudp_bound_note(self) -> Any:
|
|
return define_database_entry_type(
|
|
"mesh.rudp_bound_note",
|
|
[("schema_version", 0), ("body", 1)],
|
|
schema_id="sha256:mesh-rudp-bound",
|
|
schema_name="mesh.rudp_bound_note",
|
|
schema_version="mesh.rudp_bound_note.v1",
|
|
)
|
|
|
|
@staticmethod
|
|
def _rudp_note_put(document: Any, message_id: str, key: str, body_length: int) -> dict[str, Any]:
|
|
return document_put_raw(
|
|
message_id=message_id,
|
|
key=key,
|
|
schema_id=document.catalog_entry().schema_id,
|
|
stored_at="2026-09-30T00:00:00Z",
|
|
payload=document.encode_payload({"schema_version": "mesh.rudp_bound_note.v1", "body": "x" * body_length}),
|
|
).to_wire()
|
|
|
|
@staticmethod
|
|
def _rudp_exchange(server: Any, *messages: dict[str, Any]) -> tuple[dict[str, Any], bytes]:
|
|
"""Sends each message over RUDP in 1200-byte fragments; returns the first reply."""
|
|
transport = create_rudp_schema_transport(
|
|
host="127.0.0.1",
|
|
port=server.port,
|
|
connection_id=server.rudp_connection_id,
|
|
timeout_seconds=4.0,
|
|
max_fragment_bytes=1200,
|
|
)
|
|
try:
|
|
for message in messages:
|
|
transport.send("schema", msgpack.packb(message, use_bin_type=True))
|
|
payload = transport.receive(timeout_seconds=4.0).payload
|
|
finally:
|
|
transport.close()
|
|
return msgpack.unpackb(payload, raw=False), payload
|
|
|
|
def test_cultmesh_local_server_serves_a_100_kb_put_over_rudp_in_fragments(self) -> None:
|
|
# The reply is larger than any one UDP datagram: it arrives only because it is sent
|
|
# in fragments, and the RUDP thread serves on afterwards.
|
|
document = self._rudp_bound_note()
|
|
node = CultMesh.create_node(runtime_id="mesh-rudp-large")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node)
|
|
try:
|
|
served, payload = self._rudp_exchange(
|
|
server,
|
|
self._rudp_note_put(document, "put-large", "note:large", 100_000),
|
|
snapshot_request(message_id="large").to_wire(),
|
|
)
|
|
again, _ = self._rudp_exchange(server, snapshot_request(message_id="again").to_wire())
|
|
alive = server._rudp_thread is not None and server._rudp_thread.is_alive()
|
|
finally:
|
|
server.stop()
|
|
self.assertEqual(served["schemaVersion"], "cultnet.snapshot_response_raw.v0")
|
|
self.assertEqual([record["recordKey"] for record in served["documents"]], ["note:large"])
|
|
self.assertGreater(len(served["documents"][0]["payload"]), 100_000)
|
|
self.assertGreater(len(payload), 65_507, "fixture: the reply cannot be one UDP datagram")
|
|
self.assertEqual(again["messageId"], "again")
|
|
self.assertTrue(alive)
|
|
self.assertEqual(server.rudp_send_failures, 0)
|
|
|
|
def _rudp_body_for(self, document: Any, key: str) -> Callable[[int], int]:
|
|
"""The body length whose document, keyed `key`, is served alone under the empty message
|
|
id in exactly the given bytes, measured on an unbounded server: what a snapshot sends."""
|
|
|
|
def served_alone_bytes(body_length: int) -> int:
|
|
node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node)
|
|
try:
|
|
_, payload = self._rudp_exchange(
|
|
server, self._rudp_note_put(document, "probe", key, body_length),
|
|
snapshot_request(message_id="").to_wire(),
|
|
)
|
|
finally:
|
|
server.stop()
|
|
return len(payload)
|
|
|
|
probe = served_alone_bytes(500)
|
|
|
|
def body_for(served_bytes: int) -> int:
|
|
body_length = 500 + served_bytes - probe
|
|
self.assertEqual(served_alone_bytes(body_length), served_bytes, "fixture: served size is linear in the body")
|
|
return body_length
|
|
|
|
return body_for
|
|
|
|
def test_cultmesh_local_server_refuses_a_rudp_put_whose_reply_would_overflow_the_reliable_queue(self) -> None:
|
|
# Replies go in fragments of 100 bytes and a peer's reliable queue holds 8: a put over RUDP
|
|
# whose served reply takes 8 fragments is admitted and served, 9 or 20 are refused. A
|
|
# snapshot of two such documents needs 16 and cannot be queued: the peer is answered with a
|
|
# small cultnet.error.v0 rather than left to time out, its session ends, and the thread
|
|
# serves on.
|
|
document = self._rudp_bound_note()
|
|
body_for = self._rudp_body_for(document, "note:q")
|
|
|
|
node = CultMesh.create_node(runtime_id="mesh-rudp-bound")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8)
|
|
try:
|
|
refusals = [
|
|
self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0]
|
|
for served in (2000, 801)
|
|
]
|
|
stored_after_refusals = node.database.get(document, "note:q")
|
|
served, served_payload = self._rudp_exchange(
|
|
server,
|
|
self._rudp_note_put(document, "put-at", "note:q", body_for(800)),
|
|
snapshot_request(message_id="").to_wire(),
|
|
)
|
|
unqueueable, _ = self._rudp_exchange(
|
|
server,
|
|
self._rudp_note_put(document, "put-r", "note:r", body_for(800)),
|
|
snapshot_request(message_id="both").to_wire(),
|
|
)
|
|
failures_after = server.rudp_send_failures
|
|
# An empty snapshot fits the small queue; a hello reply would not.
|
|
after_reply, _ = self._rudp_exchange(
|
|
server, snapshot_request(message_id="after-queue", record_keys=["note:none"]).to_wire()
|
|
)
|
|
alive = server._rudp_thread is not None and server._rudp_thread.is_alive()
|
|
finally:
|
|
server.stop()
|
|
|
|
for refusal, (served_bytes, fragments) in zip(refusals, ((2000, 20), (801, 9))):
|
|
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0")
|
|
self.assertEqual(refusal["messageId"], f"put-{served_bytes}")
|
|
self.assertEqual(refusal["code"], "document_unservable")
|
|
self.assertEqual(refusal["details"], {
|
|
"responseBytes": served_bytes,
|
|
"maxSnapshotBytes": None,
|
|
"fragmentCount": fragments,
|
|
"maxFragmentCount": 8,
|
|
})
|
|
self.assertIn(f"{served_bytes} bytes; it takes {fragments} fragments", refusal["error"])
|
|
self.assertNotIn("note:q", refusal["error"])
|
|
self.assertIsNone(stored_after_refusals)
|
|
self.assertEqual(served["schemaVersion"], "cultnet.snapshot_response_raw.v0")
|
|
self.assertEqual([record["recordKey"] for record in served["documents"]], ["note:q"])
|
|
self.assertEqual(len(served_payload), 800)
|
|
self.assertEqual(unqueueable["schemaVersion"], "cultnet.error.v0")
|
|
self.assertEqual(unqueueable["messageId"], "both")
|
|
self.assertEqual(unqueueable["code"], "response_unqueueable")
|
|
self.assertEqual(failures_after, 0)
|
|
self.assertEqual(after_reply["messageId"], "after-queue")
|
|
self.assertTrue(alive)
|
|
|
|
class _RawPeer:
|
|
"""A peer driven packet by packet, so a test decides what it acknowledges: nothing."""
|
|
|
|
def __init__(self, server: Any) -> None:
|
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.sock.bind(("127.0.0.1", 0))
|
|
self.sock.settimeout(0.05)
|
|
self.address = self.sock.getsockname()[:2]
|
|
self.target = ("127.0.0.1", server.port)
|
|
self.session = CultNetRudpSession(
|
|
CultNetRudpSessionOptions(
|
|
connection_id=server.rudp_connection_id, initial_sequence=100, resend_delay_ms=100_000
|
|
)
|
|
)
|
|
self.sock.sendto(encode_rudp_packet(self.session.create_connect(0)), self.target)
|
|
accept = self.receive(1.0)
|
|
assert accept is not None and accept.packet_type == CultNetRudpPacketType.ACCEPT, accept
|
|
self.session.receive(accept, 1)
|
|
|
|
def receive(self, timeout: float = 0.3) -> Any:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
wire, _ = self.sock.recvfrom(65535)
|
|
except (TimeoutError, socket.timeout):
|
|
continue
|
|
return decode_rudp_packet(wire)
|
|
return None
|
|
|
|
def drain(self, timeout: float = 0.5) -> list[Any]:
|
|
# Bounded overall: the server resends what the peer never acknowledges.
|
|
packets = []
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
packet = self.receive(0.05)
|
|
if packet is not None:
|
|
packets.append(packet)
|
|
return packets
|
|
|
|
def send(self, message: dict[str, Any]) -> list[int]:
|
|
packets = self.session.send_many(
|
|
"schema",
|
|
msgpack.packb(message, use_bin_type=True),
|
|
CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=2),
|
|
max_fragment_bytes=1200,
|
|
)
|
|
for packet in packets:
|
|
self.sock.sendto(encode_rudp_packet(packet), self.target)
|
|
return [packet.sequence for packet in packets]
|
|
|
|
def close(self) -> None:
|
|
self.sock.close()
|
|
|
|
@staticmethod
|
|
def _acknowledges(packet: Any, sequence: int) -> bool:
|
|
return packet.ack == sequence or any(
|
|
packet.ack_mask & (1 << bit) and packet.ack > bit and packet.ack - bit - 1 == sequence
|
|
for bit in range(32)
|
|
)
|
|
|
|
def _brim_server(self) -> tuple[Any, Any]:
|
|
"""A server whose peer's reliable queue (8) is filled exactly by an 8-fragment reply the
|
|
peer never acknowledges, and the peer."""
|
|
document = self._rudp_bound_note()
|
|
node = CultMesh.create_node(runtime_id="mesh-rudp-brim")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8)
|
|
|
|
def served(body: int) -> int:
|
|
put = self._rudp_note_put(document, "p", "note:q", body)
|
|
return len(msgpack.packb(node.database.raw_put_served_alone(put, message_id=""), use_bin_type=True))
|
|
|
|
body = 400 + 800 - served(400)
|
|
self.assertEqual(served(body), 800, "fixture: served size is linear in the body")
|
|
node.database.apply_raw_put_message(self._rudp_note_put(document, "p", "note:q", body))
|
|
peer = self._RawPeer(server)
|
|
peer.send(snapshot_request(message_id="", record_keys=["note:q"]).to_wire())
|
|
first = {p.sequence for p in peer.drain(0.3) if p.packet_type == CultNetRudpPacketType.DATA}
|
|
self.assertEqual(len(first), 8, "fixture: the first reply fills the queue exactly")
|
|
return server, peer
|
|
|
|
def test_cultmesh_local_server_answers_a_peer_whose_queue_is_full_to_the_brim(self) -> None:
|
|
# The next reply cannot be queued. The peer is answered with a cultnet.error.v0 that takes
|
|
# no room in the full queue and acknowledges nothing the peer sent, then its session ends.
|
|
server, peer = self._brim_server()
|
|
try:
|
|
refused = peer.send(snapshot_request(message_id="b", record_keys=["note:none"]).to_wire())
|
|
packets = peer.drain(0.5)
|
|
alive = server._rudp_thread is not None and server._rudp_thread.is_alive()
|
|
finally:
|
|
peer.close()
|
|
server.stop()
|
|
parts = sorted(
|
|
(
|
|
p for p in packets
|
|
if p.packet_type == CultNetRudpPacketType.DATA and p.channel_id == "schema" and not p.reliable
|
|
),
|
|
key=lambda p: p.fragment_index,
|
|
)
|
|
self.assertTrue(parts, "the refusal reaches the peer")
|
|
for part in parts:
|
|
self.assertFalse(
|
|
any(self._acknowledges(part, sequence) for sequence in refused),
|
|
f"the refusal acknowledges the refused request: ack={part.ack} mask={part.ack_mask:#x}",
|
|
)
|
|
refusal = msgpack.unpackb(b"".join(bytes(p.payload) for p in parts), raw=False)
|
|
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0")
|
|
self.assertEqual(refusal["messageId"], "b")
|
|
self.assertEqual(refusal["code"], "response_unqueueable")
|
|
goodbyes = [bytes(p.payload) for p in packets if p.packet_type == CultNetRudpPacketType.DISCONNECT]
|
|
self.assertEqual(goodbyes, [b"session refused a packet"], "then the session ends")
|
|
self.assertTrue(alive)
|
|
|
|
def test_cultmesh_local_server_names_a_refusal_that_can_never_be_sent_in_the_goodbye(self) -> None:
|
|
# The refusal's own fragment fails permanently: the failure is not dropped, the goodbye
|
|
# names it by its fixed code, never the error's text.
|
|
import errno
|
|
|
|
server, peer = self._brim_server()
|
|
original_sendto = socket.socket.sendto
|
|
|
|
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
|
packet = decode_rudp_packet(data)
|
|
if (
|
|
args and tuple(args[-1]) == tuple(peer.address)
|
|
and packet.packet_type == CultNetRudpPacketType.DATA and not packet.reliable
|
|
):
|
|
raise OSError(errno.EMSGSIZE, "Message too long CANARY-7f3a")
|
|
return original_sendto(sock, data, *args)
|
|
|
|
try:
|
|
with patch.object(socket.socket, "sendto", sendto):
|
|
peer.send(snapshot_request(message_id="b", record_keys=["note:none"]).to_wire())
|
|
packets = peer.drain(0.5)
|
|
alive = server._rudp_thread is not None and server._rudp_thread.is_alive()
|
|
finally:
|
|
peer.close()
|
|
server.stop()
|
|
self.assertFalse(
|
|
[p for p in packets if p.packet_type == CultNetRudpPacketType.DATA and not p.reliable],
|
|
"the refusal could not be sent",
|
|
)
|
|
goodbyes = [bytes(p.payload) for p in packets if p.packet_type == CultNetRudpPacketType.DISCONNECT]
|
|
self.assertEqual(goodbyes, [b"packet could not be sent: EMSGSIZE"])
|
|
self.assertTrue(alive)
|
|
|
|
def test_cultmesh_local_server_refuses_a_tcp_message_it_cannot_handle_and_serves_on(self) -> None:
|
|
# Messages whose handling raises: the TCP peer is refused with fixed text, the log names
|
|
# the error type only, and the connection's thread serves its next message.
|
|
shapes = [
|
|
{"schemaVersion": "cultnet.snapshot_request.v0", "messageId": "snapshot", "recordKeys": 5},
|
|
{"schemaVersion": "cultnet.shard_log_request.v0", "messageId": "shard-log", "shardId": "a", "afterSequence": "CANARY-7f3a"},
|
|
{"schemaVersion": "cultnet.database_subscribe.v0", "messageId": "subscribe", "subscriptionId": "s", "schemaIds": 5},
|
|
{"schemaVersion": "cultnet.schema_catalog_request.v0", "messageId": "catalog", "schemaIds": 5},
|
|
{"schemaVersion": VERSE_CATALOG_REQUEST, "messageId": "verse", "verseIds": 5},
|
|
]
|
|
deaths: list[str] = []
|
|
original_hook = threading.excepthook
|
|
threading.excepthook = lambda args: deaths.append(f"{args.exc_type.__name__}")
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-tcp-shapes"))
|
|
try:
|
|
with self.assertLogs("cultmesh_py.server", level="WARNING") as logs:
|
|
with CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=4.0).open_transport() as transport:
|
|
refusals = []
|
|
for shape in shapes:
|
|
transport.send("schema", msgpack.packb(shape, use_bin_type=True))
|
|
refusals.append((shape["messageId"], msgpack.unpackb(transport.receive().payload, raw=False)))
|
|
transport.send("schema", msgpack.packb(hello(runtime_id="same-connection").to_wire(), use_bin_type=True))
|
|
after = msgpack.unpackb(transport.receive().payload, raw=False)
|
|
finally:
|
|
server.stop()
|
|
threading.excepthook = original_hook
|
|
for message_id, refusal in refusals:
|
|
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
|
self.assertEqual(refusal["messageId"], message_id)
|
|
self.assertEqual(refusal["code"], "message_unhandled", message_id)
|
|
self.assertEqual(refusal["error"], "The message could not be handled.", message_id)
|
|
self.assertEqual(after["schemaVersion"], "cultnet.hello.v0")
|
|
self.assertEqual(deaths, [])
|
|
self.assertEqual(len(logs.output), len(shapes))
|
|
self.assertFalse([line for line in logs.output if "CANARY" in line])
|
|
|
|
def test_cultmesh_local_server_refuses_a_rudp_put_whose_reply_needs_more_than_65535_fragments(self) -> None:
|
|
# One message carries at most 65535 fragments, however deep the queue: with one-byte
|
|
# fragments a put served in 65536 bytes is refused, one served in 65535 is stored.
|
|
document = self._rudp_bound_note()
|
|
body_for = self._rudp_body_for(document, "note:w")
|
|
node = CultMesh.create_node(runtime_id="mesh-rudp-wide")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=1)
|
|
try:
|
|
transport = create_rudp_schema_transport(
|
|
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id,
|
|
timeout_seconds=4.0, max_fragment_bytes=1200,
|
|
)
|
|
try:
|
|
transport.send("schema", msgpack.packb(self._rudp_note_put(document, "put-over", "note:w", body_for(65_536)), use_bin_type=True))
|
|
refusal = msgpack.unpackb(transport.receive(timeout_seconds=8.0).payload, raw=False)
|
|
transport.send("schema", msgpack.packb(self._rudp_note_put(document, "put-at", "note:w", body_for(65_535)), use_bin_type=True))
|
|
deadline = time.monotonic() + 4.0
|
|
while node.database.get(document, "note:w") is None and time.monotonic() < deadline:
|
|
transport.receive_once()
|
|
time.sleep(0.005)
|
|
finally:
|
|
transport.close()
|
|
stored = node.database.get(document, "note:w")
|
|
finally:
|
|
server.stop()
|
|
self.assertEqual(refusal["code"], "document_unservable")
|
|
self.assertEqual(refusal["details"]["fragmentCount"], 65_536)
|
|
self.assertEqual(refusal["details"]["maxFragmentCount"], 65_535)
|
|
self.assertIsNotNone(stored)
|
|
|
|
def test_cultmesh_local_server_rudp_thread_survives_a_datagram_that_cannot_leave(self) -> None:
|
|
# Fragments larger than any UDP datagram: the reply's one packet can never be sent as
|
|
# built. That is a permanent failure: it ends that peer's session and is not counted as
|
|
# a lost datagram; the thread serves the next peer.
|
|
document = self._rudp_bound_note()
|
|
node = CultMesh.create_node(runtime_id="mesh-rudp-emsgsize")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=200_000)
|
|
try:
|
|
transport = create_rudp_schema_transport(
|
|
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id,
|
|
timeout_seconds=4.0, max_fragment_bytes=1200,
|
|
)
|
|
try:
|
|
transport.send("schema", msgpack.packb(self._rudp_note_put(document, "put-large", "note:big", 80_000), use_bin_type=True))
|
|
transport.send("schema", msgpack.packb(snapshot_request(message_id="big").to_wire(), use_bin_type=True))
|
|
deadline = time.monotonic() + 1.0
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
transport.receive_once()
|
|
except ConnectionError:
|
|
break
|
|
time.sleep(0.005)
|
|
finally:
|
|
transport.close()
|
|
failures = server.rudp_send_failures
|
|
hello_reply, _ = self._rudp_exchange(server, hello(runtime_id="after").to_wire())
|
|
alive = server._rudp_thread is not None and server._rudp_thread.is_alive()
|
|
finally:
|
|
server.stop()
|
|
self.assertEqual(failures, 0)
|
|
self.assertEqual(hello_reply["schemaVersion"], "cultnet.hello.v0")
|
|
self.assertTrue(alive)
|
|
|
|
def test_cultmesh_local_server_refuses_a_malformed_put_over_rudp_and_serves_on(self) -> None:
|
|
# A put whose document has no string recordKey or no binary payload could never be stored
|
|
# or served: it is refused as malformed before any sizing reads it, and the RUDP thread
|
|
# serves the next peer.
|
|
document = self._rudp_bound_note()
|
|
node = CultMesh.create_node(runtime_id="mesh-rudp-malformed")
|
|
node.database.register_document(document)
|
|
server = CultMesh.serve_node(node)
|
|
try:
|
|
refusals = []
|
|
for field_name, value in (
|
|
("recordKey", None),
|
|
("recordKey", 5),
|
|
("payload", None),
|
|
("payload", "not binary"),
|
|
):
|
|
put = self._rudp_note_put(document, f"bad-{field_name}-{value}", "note:bad", 10)
|
|
if value is None:
|
|
del put["document"][field_name]
|
|
else:
|
|
put["document"][field_name] = value
|
|
refusal, _ = self._rudp_exchange(server, put)
|
|
refusals.append((put["messageId"], refusal))
|
|
after, _ = self._rudp_exchange(server, snapshot_request(message_id="after").to_wire())
|
|
alive = server._rudp_thread is not None and server._rudp_thread.is_alive()
|
|
finally:
|
|
server.stop()
|
|
for message_id, refusal in refusals:
|
|
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
|
self.assertEqual(refusal["messageId"], message_id)
|
|
self.assertEqual(refusal["code"], "malformed_document_put", message_id)
|
|
self.assertEqual(after["messageId"], "after")
|
|
self.assertEqual(after["documents"], [])
|
|
self.assertTrue(alive)
|
|
|
|
def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None:
|
|
# Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a
|
|
# payload its schema cannot decode, a global document under another key. Each is refused
|
|
# to the peer that sent it, over RUDP and over TCP; the RUDP thread lives, the TCP
|
|
# connection serves its next message, and other peers are served.
|
|
document = self._rudp_bound_note()
|
|
global_document = define_database_entry_type(
|
|
"mesh.rudp_global_note",
|
|
[("schema_version", 0), ("body", 1)],
|
|
schema_id="sha256:mesh-rudp-global",
|
|
schema_name="mesh.rudp_global_note",
|
|
schema_version="mesh.rudp_global_note.v1",
|
|
global_document=True,
|
|
)
|
|
|
|
def cases() -> list[dict[str, Any]]:
|
|
puts = []
|
|
for name, field_name, value in (
|
|
("empty-key", "recordKey", ""),
|
|
("blank-key", "recordKey", " "),
|
|
("garbage-payload", "payload", bytes([0xC1])),
|
|
("int-payload", "payload", msgpack.packb(5)),
|
|
):
|
|
put = self._rudp_note_put(document, name, "note:ok", 10)
|
|
put["document"][field_name] = value
|
|
puts.append(put)
|
|
puts.append(self._rudp_note_put(global_document, "global-wrong-key", "not-global", 10))
|
|
return puts
|
|
|
|
node = CultMesh.create_node(runtime_id="mesh-unstorable-put")
|
|
node.database.register_document(document)
|
|
node.database.register_document(global_document)
|
|
server = CultMesh.serve_node(node)
|
|
try:
|
|
rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()]
|
|
rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire())
|
|
rudp_alive = server._rudp_thread is not None and server._rudp_thread.is_alive()
|
|
|
|
tcp_refusals = []
|
|
with CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=4.0).open_transport() as transport:
|
|
for put in cases():
|
|
transport.send("schema", msgpack.packb(put, use_bin_type=True))
|
|
tcp_refusals.append((put["messageId"], msgpack.unpackb(transport.receive().payload, raw=False)))
|
|
transport.send("schema", msgpack.packb(hello(runtime_id="same-connection").to_wire(), use_bin_type=True))
|
|
same_connection = msgpack.unpackb(transport.receive().payload, raw=False)
|
|
other_connection = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=4.0).request(
|
|
hello(runtime_id="other-connection").to_wire(), expected_schema_version="cultnet.hello.v0"
|
|
)
|
|
finally:
|
|
server.stop()
|
|
self.assertEqual(len(rudp_refusals), 5)
|
|
for message_id, refusal in rudp_refusals + tcp_refusals:
|
|
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
|
self.assertEqual(refusal["messageId"], message_id)
|
|
self.assertEqual(refusal["code"], "malformed_document_put", message_id)
|
|
self.assertNotIn("note:ok", refusal["error"], message_id)
|
|
self.assertEqual(len(tcp_refusals), 5)
|
|
self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0")
|
|
self.assertTrue(rudp_alive)
|
|
self.assertEqual(same_connection["schemaVersion"], "cultnet.hello.v0")
|
|
self.assertEqual(other_connection["schemaVersion"], "cultnet.hello.v0")
|
|
self.assertIsNone(node.database.get(document, "note:ok"))
|
|
|
|
def test_cultmesh_local_server_refuses_options_that_could_answer_nothing(self) -> None:
|
|
# The Rust document server's option rules: every limit is greater than zero, and the limits
|
|
# leave room for an empty snapshot, the smallest response the server sends. One-byte
|
|
# fragments and a queue of 16 could never carry one, so no reply would ever be sent.
|
|
node = CultMesh.create_node(runtime_id="mesh-rudp-options")
|
|
probe = CultMesh.serve_node(node)
|
|
try:
|
|
_, empty_payload = self._rudp_exchange(probe, snapshot_request(message_id="").to_wire())
|
|
finally:
|
|
probe.stop()
|
|
empty = len(empty_payload)
|
|
self.assertGreater(empty, 16, "fixture: an empty snapshot takes more than 16 one-byte fragments")
|
|
self.assertNotEqual(empty % (empty - 1), 0, "fixture: empty - 1 byte fragments leave a remainder")
|
|
|
|
def refused(**options: Any) -> str:
|
|
server = None
|
|
try:
|
|
with self.assertRaises(ValueError, msg=str(options)) as raised:
|
|
server = CultMesh.serve_node(node, **options)
|
|
finally:
|
|
if server is not None:
|
|
server.stop()
|
|
return str(raised.exception)
|
|
|
|
def accepted(**options: Any) -> None:
|
|
CultMesh.serve_node(node, **options).stop()
|
|
|
|
carry = "cannot carry an empty snapshot response"
|
|
self.assertIn(carry, refused(rudp_max_fragment_bytes=1, rudp_max_pending_reliable_packets=16))
|
|
self.assertIn(carry, refused(rudp_max_fragment_bytes=1, rudp_max_pending_reliable_packets=empty - 1))
|
|
accepted(rudp_max_fragment_bytes=1, rudp_max_pending_reliable_packets=empty)
|
|
# Fragments are counted rounding up: empty - 1 bytes a fragment takes two.
|
|
self.assertIn(carry, refused(rudp_max_fragment_bytes=empty - 1, rudp_max_pending_reliable_packets=1))
|
|
accepted(rudp_max_fragment_bytes=empty - 1, rudp_max_pending_reliable_packets=2)
|
|
self.assertIn("cannot hold an empty snapshot response", refused(max_snapshot_bytes=empty - 1))
|
|
accepted(max_snapshot_bytes=empty)
|
|
self.assertIn("greater than zero", refused(max_snapshot_bytes=0))
|
|
self.assertIn("greater than zero", refused(max_snapshot_documents=0))
|
|
accepted(max_snapshot_documents=1)
|
|
self.assertIn("greater than zero", refused(rudp_resend_delay_ms=0))
|
|
accepted(rudp_resend_delay_ms=1)
|
|
self.assertIn("greater than zero", refused(rudp_max_fragment_bytes=0))
|
|
self.assertIn("greater than zero", refused(rudp_max_pending_reliable_packets=0))
|
|
# The RUDP options configure only the RUDP server: without one, none of them is checked.
|
|
accepted(enable_rudp=False, rudp_max_fragment_bytes=1, rudp_max_pending_reliable_packets=16)
|
|
accepted(
|
|
enable_rudp=False,
|
|
rudp_max_fragment_bytes=0,
|
|
rudp_max_pending_reliable_packets=0,
|
|
rudp_resend_delay_ms=0,
|
|
)
|
|
self.assertIn("greater than zero", refused(enable_rudp=False, max_snapshot_documents=0))
|
|
self.assertIn("cannot hold an empty snapshot response", refused(enable_rudp=False, max_snapshot_bytes=empty - 1))
|
|
|
|
# The configuration at the bound does answer: an empty snapshot in one-byte fragments,
|
|
# exactly as many as the queue holds.
|
|
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=1, rudp_max_pending_reliable_packets=empty)
|
|
try:
|
|
served, payload = self._rudp_exchange(server, snapshot_request(message_id="").to_wire())
|
|
finally:
|
|
server.stop()
|
|
self.assertEqual(served["documents"], [])
|
|
self.assertEqual(len(payload), empty)
|
|
self.assertEqual(server.rudp_send_failures, 0)
|
|
|
|
def test_cultmesh_local_server_returns_errors_for_bad_requests(self) -> None:
|
|
document = define_database_entry_type(
|
|
"mesh.error_note",
|
|
[("body", 0)],
|
|
schema_id="mesh.error_note.v1",
|
|
)
|
|
node = CultMesh.create_node(runtime_id="mesh-error-server")
|
|
node.database.register_document(document)
|
|
|
|
server = CultMesh.serve_node(node)
|
|
try:
|
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
unsupported = raw_client.request(
|
|
{"schemaVersion": "cultnet.nope.v0", "messageId": "bad-schema"},
|
|
expected_schema_version="cultnet.error.v0",
|
|
)
|
|
malformed_put = raw_client.request(
|
|
{"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "bad-put"},
|
|
expected_schema_version="cultnet.error.v0",
|
|
)
|
|
malformed_delete = raw_client.request(
|
|
{"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"},
|
|
expected_schema_version="cultnet.error.v0",
|
|
)
|
|
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this v0-only Mesh server
|
|
# refuses a cultnet.selection.v1 carrier message through the same delegating path
|
|
# (_handle_connection_message -> handle_message) as any other unsupported schema, not a
|
|
# silent empty response.
|
|
unsupported_v1 = raw_client.request(
|
|
{"schemaVersion": "cultnet.snapshot_request.v1", "messageId": "bad-v1-selection"},
|
|
expected_schema_version="cultnet.error.v0",
|
|
)
|
|
finally:
|
|
server.stop()
|
|
|
|
self.assertEqual(unsupported["messageId"], "bad-schema")
|
|
self.assertEqual(unsupported["code"], "unsupported_schema_version")
|
|
self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0")
|
|
self.assertIn("Unsupported CultNet message schema", unsupported["error"])
|
|
self.assertEqual(malformed_put["messageId"], "bad-put")
|
|
self.assertEqual(malformed_put["code"], "malformed_document_put")
|
|
self.assertEqual(malformed_delete["messageId"], "bad-delete")
|
|
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
|
|
self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection")
|
|
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
|
|
self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1")
|
|
|
|
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
|
|
peer = CultMeshPeerCard(
|
|
peer_id="voidbot-local",
|
|
verse_id="local",
|
|
endpoints=("cultmesh://localhost",),
|
|
roles=("shard-primary",),
|
|
authority_lease_id="lease:voidbot-local",
|
|
)
|
|
leases = CultMeshAuthorityLeaseCatalog()
|
|
peers = CultMeshPeerCatalog()
|
|
peers.upsert(peer)
|
|
now = datetime.now(UTC)
|
|
self.assertFalse(leases.is_authorized(peer, "shard-primary", at=now))
|
|
self.assertEqual(peers.find_authorized("local", "shard-primary", leases, at=now), [])
|
|
|
|
leases.upsert(
|
|
CultMeshAuthorityLease(
|
|
lease_id="lease:voidbot-local",
|
|
verse_id="local",
|
|
peer_id="voidbot-local",
|
|
roles=("shard-primary", "shard-primary", ""),
|
|
valid_from=now - timedelta(seconds=1),
|
|
expires_at=now + timedelta(seconds=1),
|
|
shard_ids=("shard-a",),
|
|
issuer_runtime_id="odin",
|
|
signature="signed",
|
|
)
|
|
)
|
|
|
|
lease = leases.get("lease:voidbot-local")
|
|
self.assertIsNotNone(lease)
|
|
assert lease is not None
|
|
self.assertEqual(leases.leases, (lease,))
|
|
self.assertEqual(lease.roles, ("shard-primary",))
|
|
self.assertEqual(lease.issuer_runtime_id, "odin")
|
|
self.assertEqual(lease.signature, "signed")
|
|
self.assertTrue(lease.is_valid_at(now))
|
|
self.assertTrue(lease.covers(peer, "shard-primary", shard_id="shard-a", at=now))
|
|
self.assertFalse(lease.covers(peer, "shard-primary", shard_id="shard-b", at=now))
|
|
self.assertTrue(leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
|
|
self.assertEqual(
|
|
peers.find_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now),
|
|
[peer],
|
|
)
|
|
self.assertEqual(
|
|
peers.first_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now),
|
|
peer,
|
|
)
|
|
self.assertIsNone(peers.first_authorized("local", "read-replica", leases, at=now))
|
|
self.assertFalse(leases.is_authorized(peer, "shard-primary", shard_id="shard-b", at=now))
|
|
self.assertFalse(leases.is_authorized(peer, "read-replica", at=now))
|
|
self.assertIsNone(leases.get("missing"))
|
|
|
|
def test_cultmesh_authority_lease_catalog_can_require_verified_signatures(self) -> None:
|
|
peer = CultMeshPeerCard(
|
|
peer_id="voidbot-local",
|
|
verse_id="local",
|
|
endpoints=("cultmesh://localhost",),
|
|
roles=("shard-primary",),
|
|
shard_ids=("shard-a",),
|
|
authority_lease_id="lease:voidbot-local",
|
|
)
|
|
now = datetime.now(UTC)
|
|
unsigned = CultMeshAuthorityLease(
|
|
lease_id="lease:voidbot-local",
|
|
verse_id="local",
|
|
peer_id="voidbot-local",
|
|
roles=("shard-primary",),
|
|
valid_from=now - timedelta(seconds=1),
|
|
expires_at=now + timedelta(seconds=30),
|
|
shard_ids=("shard-a",),
|
|
issuer_runtime_id="odin",
|
|
)
|
|
verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"lease-secret"})
|
|
signed = verifier.issue(unsigned)
|
|
strict_leases = CultMesh.create_authority_lease_catalog(
|
|
signature_verifier=verifier.verify,
|
|
require_verified_signatures=True,
|
|
)
|
|
strict_leases.upsert(unsigned)
|
|
self.assertFalse(strict_leases.is_verified(unsigned.lease_id))
|
|
self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
|
|
|
|
strict_leases.upsert(signed)
|
|
self.assertTrue(strict_leases.is_verified(signed.lease_id))
|
|
self.assertTrue(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
|
|
|
|
wrong_key = CultMesh.create_authority_lease_catalog(
|
|
signature_verifier=CultMeshHmacAuthorityLeaseVerifier({"odin": b"wrong-secret"}).verify,
|
|
require_verified_signatures=True,
|
|
)
|
|
wrong_key.upsert(signed)
|
|
self.assertFalse(wrong_key.is_verified(signed.lease_id))
|
|
self.assertFalse(wrong_key.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
|
|
|
|
tampered = CultMeshAuthorityLease(
|
|
lease_id=signed.lease_id,
|
|
verse_id=signed.verse_id,
|
|
peer_id=signed.peer_id,
|
|
roles=("shard-primary", "read-replica"),
|
|
valid_from=signed.valid_from,
|
|
expires_at=signed.expires_at,
|
|
shard_ids=signed.shard_ids,
|
|
issuer_runtime_id=signed.issuer_runtime_id,
|
|
signature=signed.signature,
|
|
)
|
|
strict_leases.upsert(tampered)
|
|
self.assertFalse(strict_leases.is_verified(tampered.lease_id))
|
|
self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
|
|
|
|
def test_cultmesh_stream_catalog_negotiates_transport_and_latest_frame(self) -> None:
|
|
streams = CultMeshStreamCatalog()
|
|
streams.declare(
|
|
CultMeshStreamDescriptor(
|
|
stream_id="mimir:kiyo-pro",
|
|
verse_id="studio",
|
|
owner_peer_id="starfire",
|
|
kind="video",
|
|
label="Kiyo Pro",
|
|
clock={"clockDomainId": "starfire-qpc", "confidence": 0.25},
|
|
video={"width": 1920, "height": 1080, "pixelFormat": "YUY2", "framesPerSecond": 30},
|
|
preferred_transports=("shared-d3d12-texture", "shared-memory", "cultcache-page"),
|
|
max_in_flight_frames=3,
|
|
)
|
|
)
|
|
|
|
negotiation = streams.negotiate(
|
|
"mimir:kiyo-pro",
|
|
CultMeshStreamConsumerProfile(
|
|
peer_id="fensalir",
|
|
verse_id="studio",
|
|
supported_transports=("shared-d3d12-texture", "cultcache-page"),
|
|
accepted_kinds=("video",),
|
|
can_import_gpu_handles=True,
|
|
max_in_flight_frames=2,
|
|
),
|
|
)
|
|
|
|
self.assertEqual(negotiation.transport, "shared-d3d12-texture")
|
|
self.assertEqual(negotiation.max_in_flight_frames, 2)
|
|
self.assertEqual(negotiation.copy_budget, "zero-copy-target")
|
|
|
|
streams.publish_frame(
|
|
CultMeshStreamFrameHandle(
|
|
stream_id="mimir:kiyo-pro",
|
|
sequence=42,
|
|
timestamp_ns=1_000_000_000,
|
|
duration_ns=33_333_334,
|
|
transport="shared-d3d12-texture",
|
|
native_handle="0xfeed",
|
|
fence_handle="0xbeef",
|
|
fence_value=7,
|
|
unavoidable_copy_count=0,
|
|
)
|
|
)
|
|
self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42)
|
|
|
|
|
|
class CultMeshRudpSendFailureTests(unittest.TestCase):
|
|
"""One peer whose datagrams cannot be sent must not stop the RUDP thread
|
|
serving the others."""
|
|
|
|
def _client(self, server: Any, name: str) -> tuple[CultNetRudpSocketTransportConnection, tuple[str, int]]:
|
|
sock = bind_udp_socket()
|
|
client = CultNetRudpSocketTransportConnection(
|
|
CultNetRudpSocketTransportOptions(
|
|
runtime_id=name,
|
|
socket=sock,
|
|
mode=CultNetRudpSocketMode.CLIENT,
|
|
connection_id=server.rudp_connection_id,
|
|
remote_addr=("127.0.0.1", server.port),
|
|
resend_delay_ms=25,
|
|
)
|
|
)
|
|
client.connect(b"join")
|
|
for _ in range(200):
|
|
client.receive_once()
|
|
if client.connected:
|
|
break
|
|
self.assertTrue(client.connected)
|
|
return client, sock.getsockname()[:2]
|
|
|
|
def test_a_failed_send_to_one_peer_does_not_kill_the_rudp_thread(self) -> None:
|
|
import msgpack # type: ignore
|
|
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
original_sendto = socket.socket.sendto
|
|
try:
|
|
x, x_addr = self._client(server, "peer-x")
|
|
y, _ = self._client(server, "peer-y")
|
|
|
|
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
|
if args and tuple(args[-1]) == tuple(x_addr):
|
|
raise OSError("injected send failure")
|
|
return original_sendto(sock, data, *args)
|
|
|
|
request = hello(runtime_id="probe").to_bytes()
|
|
with patch.object(socket.socket, "sendto", sendto):
|
|
# The server's reply and ack to X both fail to send.
|
|
x.send("schema", request)
|
|
self._wait_for(lambda: server.rudp_send_failures > 0)
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
|
|
y.send("schema", request)
|
|
response = None
|
|
for _ in range(200):
|
|
response = y.receive_once()
|
|
if response is not None:
|
|
break
|
|
time.sleep(0.005)
|
|
|
|
self.assertIsNotNone(response, "Y is still served after X's send failed")
|
|
self.assertEqual(msgpack.unpackb(response.payload, raw=False)["runtimeId"], "mesh-server")
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
self.assertGreater(server.rudp_send_failures, 0)
|
|
finally:
|
|
server.stop()
|
|
|
|
def _served(self, y: CultNetRudpSocketTransportConnection) -> Any:
|
|
import msgpack # type: ignore
|
|
|
|
y.send("schema", hello(runtime_id="probe").to_bytes())
|
|
for _ in range(200):
|
|
response = y.receive_once()
|
|
if response is not None:
|
|
return msgpack.unpackb(response.payload, raw=False)
|
|
time.sleep(0.005)
|
|
return None
|
|
|
|
def test_a_vanished_peer_does_not_kill_the_rudp_thread(self) -> None:
|
|
# On Windows the closed port of a peer that went away is reported as
|
|
# ConnectionResetError (WinError 10054) by the next receive. It can only fail there.
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
try:
|
|
x, _ = self._client(server, "peer-x")
|
|
y, _ = self._client(server, "peer-y")
|
|
x.send("schema", hello(runtime_id="probe").to_bytes())
|
|
x.socket.close()
|
|
time.sleep(1.0) # the server's reply and resends to X draw ICMP port-unreachable
|
|
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
served = self._served(y)
|
|
self.assertIsNotNone(served, "Y is still served after X vanished")
|
|
self.assertEqual(served["runtimeId"], "mesh-server")
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_a_reset_reported_by_a_receive_is_idle_not_the_end_of_the_thread(self) -> None:
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
original_recvfrom = socket.socket.recvfrom
|
|
resets = [3]
|
|
try:
|
|
y, _ = self._client(server, "peer-y")
|
|
|
|
def recvfrom(sock: socket.socket, *args: Any) -> Any:
|
|
if sock is server._rudp_socket and resets[0] > 0:
|
|
resets[0] -= 1
|
|
raise ConnectionResetError(errno.ECONNRESET, "injected reset")
|
|
return original_recvfrom(sock, *args)
|
|
|
|
with patch.object(socket.socket, "recvfrom", recvfrom):
|
|
self._wait_for(lambda: resets[0] == 0)
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
self.assertIsNotNone(self._served(y))
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_a_permanent_send_failure_ends_only_that_peers_session_and_the_goodbye_names_it(self) -> None:
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
original_sendto = socket.socket.sendto
|
|
try:
|
|
x, x_addr = self._client(server, "peer-x")
|
|
y, _ = self._client(server, "peer-y")
|
|
failures = [1]
|
|
|
|
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
|
if args and tuple(args[-1]) == tuple(x_addr) and failures[0] > 0:
|
|
failures[0] -= 1
|
|
raise OSError(errno.EMSGSIZE, "Message too long")
|
|
return original_sendto(sock, data, *args)
|
|
|
|
with patch.object(socket.socket, "sendto", sendto):
|
|
# The server's response to X can never be sent as built.
|
|
x.send("schema", hello(runtime_id="probe").to_bytes())
|
|
for _ in range(200):
|
|
x.receive_once()
|
|
if x.disconnect_reason is not None:
|
|
break
|
|
time.sleep(0.005)
|
|
self.assertIsNotNone(x.disconnect_reason, "X is told its session ended")
|
|
self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: "))
|
|
self.assertGreater(len(x.disconnect_reason), len(b"packet could not be sent: "))
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
|
|
# Y is unaffected.
|
|
self.assertFalse(x.connected)
|
|
self.assertIsNotNone(self._served(y))
|
|
self.assertEqual(server.rudp_send_failures, 0)
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_healthy_sends_are_not_failures(self) -> None:
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
try:
|
|
x, _ = self._client(server, "peer-x")
|
|
self.assertIsNotNone(self._served(x))
|
|
self.assertEqual(server.rudp_send_failures, 0)
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_a_permanent_failure_in_a_resend_ends_only_that_peers_session(self) -> None:
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
original_sendto = socket.socket.sendto
|
|
try:
|
|
x, x_addr = self._client(server, "peer-x")
|
|
y, _ = self._client(server, "peer-y")
|
|
mode = ["transient"]
|
|
|
|
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
|
if args and tuple(args[-1]) == tuple(x_addr) and mode[0] == "transient":
|
|
raise OSError(errno.ENOBUFS, "injected transient failure")
|
|
if args and tuple(args[-1]) == tuple(x_addr) and mode[0] == "permanent":
|
|
mode[0] = "healthy"
|
|
raise OSError(errno.EMSGSIZE, "Message too long")
|
|
return original_sendto(sock, data, *args)
|
|
|
|
with patch.object(socket.socket, "sendto", sendto):
|
|
# X's response is lost, so a resend is pending; then it can never be sent as built.
|
|
x.send("schema", hello(runtime_id="probe").to_bytes())
|
|
self._wait_for(lambda: server.rudp_send_failures > 0)
|
|
self.assertFalse(x.disconnect_reason, "a transient failure ends no session")
|
|
mode[0] = "permanent"
|
|
for _ in range(200):
|
|
x.receive_once()
|
|
if x.disconnect_reason is not None:
|
|
break
|
|
time.sleep(0.005)
|
|
self.assertIsNotNone(x.disconnect_reason)
|
|
self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: "))
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
self.assertIsNotNone(self._served(y))
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_a_permanent_failure_acknowledging_a_frame_ends_the_session(self) -> None:
|
|
import msgpack # type: ignore
|
|
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
original_sendto = socket.socket.sendto
|
|
try:
|
|
x, x_addr = self._client(server, "peer-x")
|
|
failures = [1]
|
|
|
|
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
|
if args and tuple(args[-1]) == tuple(x_addr) and failures[0] > 0:
|
|
failures[0] -= 1
|
|
raise OSError(errno.EMSGSIZE, "Message too long")
|
|
return original_sendto(sock, data, *args)
|
|
|
|
with patch.object(socket.socket, "sendto", sendto):
|
|
# Not a message the server answers, so its ack is the only datagram owed.
|
|
x.send("schema", msgpack.packb(1))
|
|
for _ in range(200):
|
|
x.receive_once()
|
|
if x.disconnect_reason is not None:
|
|
break
|
|
time.sleep(0.005)
|
|
self.assertIsNotNone(x.disconnect_reason)
|
|
self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: "))
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_a_connect_whose_accept_can_never_be_sent_starts_no_session(self) -> None:
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
original_sendto = socket.socket.sendto
|
|
try:
|
|
y, _ = self._client(server, "peer-y")
|
|
z_socket = bind_udp_socket()
|
|
z_addr = z_socket.getsockname()[:2]
|
|
failures = [1]
|
|
|
|
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
|
if args and tuple(args[-1]) == tuple(z_addr) and failures[0] > 0:
|
|
failures[0] -= 1
|
|
raise OSError(errno.EMSGSIZE, "Message too long")
|
|
return original_sendto(sock, data, *args)
|
|
|
|
with patch.object(socket.socket, "sendto", sendto):
|
|
z = CultNetRudpSocketTransportConnection(
|
|
CultNetRudpSocketTransportOptions(
|
|
runtime_id="peer-z",
|
|
socket=z_socket,
|
|
mode=CultNetRudpSocketMode.CLIENT,
|
|
connection_id=server.rudp_connection_id,
|
|
remote_addr=("127.0.0.1", server.port),
|
|
resend_delay_ms=25,
|
|
)
|
|
)
|
|
z.connect(b"join")
|
|
# A session that started would resend its Accept, and the resend would get through.
|
|
time.sleep(0.5)
|
|
z_socket.settimeout(0.05)
|
|
received = []
|
|
while True:
|
|
try:
|
|
wire, _ = z_socket.recvfrom(65535)
|
|
except TimeoutError:
|
|
break
|
|
received.append(decode_rudp_packet(wire).packet_type)
|
|
self.assertEqual(received, [CultNetRudpPacketType.DISCONNECT])
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
self.assertIsNotNone(self._served(y))
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_a_packet_the_session_refuses_ends_only_that_peers_session(self) -> None:
|
|
from cultnet_py import CultNetRudpPacket, encode_rudp_packet
|
|
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
try:
|
|
x, _ = self._client(server, "peer-x")
|
|
y, _ = self._client(server, "peer-y")
|
|
# A fragment whose metadata the session refuses: fragment id 0 names no set.
|
|
refused = CultNetRudpPacket(
|
|
packet_type=CultNetRudpPacketType.DATA,
|
|
connection_id=server.rudp_connection_id,
|
|
sequence=0,
|
|
ack=0,
|
|
ack_mask=0,
|
|
channel_id="schema",
|
|
reliable=False,
|
|
fragment_id=0,
|
|
fragment_index=0,
|
|
fragment_count=2,
|
|
payload=b"x",
|
|
)
|
|
x.socket.sendto(encode_rudp_packet(refused), ("127.0.0.1", server.port))
|
|
for _ in range(200):
|
|
x.receive_once()
|
|
if x.disconnect_reason is not None:
|
|
break
|
|
time.sleep(0.005)
|
|
self.assertEqual(x.disconnect_reason, b"session refused a packet")
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
self.assertIsNotNone(self._served(y), "Y is still served")
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_a_message_whose_handling_fails_ends_only_that_peers_session(self) -> None:
|
|
import msgpack # type: ignore
|
|
|
|
canary = "canary-7f3e1c"
|
|
# Well-formed frames whose fields have the wrong type: handling raises TypeError.
|
|
frames = [
|
|
{"schemaVersion": "cultnet.database_subscribe.v0", "subscriptionId": canary, "schemaIds": 5},
|
|
{"schemaVersion": "cultnet.database_subscribe.v0", "subscriptionId": canary, "recordKeys": True},
|
|
]
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
try:
|
|
y, _ = self._client(server, "peer-y")
|
|
for frame in frames:
|
|
with self.subTest(frame=frame):
|
|
x, _ = self._client(server, "peer-x")
|
|
with self.assertLogs("cultmesh_py.server", level="WARNING") as logs:
|
|
x.send("schema", msgpack.packb(frame, use_bin_type=True))
|
|
for _ in range(200):
|
|
x.receive_once()
|
|
if x.disconnect_reason is not None:
|
|
break
|
|
time.sleep(0.005)
|
|
self.assertEqual(x.disconnect_reason, b"session refused a packet")
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
self.assertIsNotNone(self._served(y), "Y is still served")
|
|
self.assertEqual(len(logs.output), 1)
|
|
self.assertIn("TypeError", logs.output[0])
|
|
self.assertNotIn(canary, logs.output[0])
|
|
finally:
|
|
server.stop()
|
|
|
|
def _raw_peer(self, server: Any) -> tuple[socket.socket, Any]:
|
|
"""A peer driven packet by packet, to deliver frames in an order a client would not."""
|
|
from cultnet_py import CultNetRudpSession, CultNetRudpSessionOptions, encode_rudp_packet
|
|
|
|
sock = bind_udp_socket()
|
|
sock.settimeout(0.05)
|
|
session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=server.rudp_connection_id))
|
|
sock.sendto(encode_rudp_packet(session.create_connect(0, b"join")), ("127.0.0.1", server.port))
|
|
wire, _ = sock.recvfrom(65535)
|
|
accept = decode_rudp_packet(wire)
|
|
self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT)
|
|
session.receive(accept, 0)
|
|
return sock, session
|
|
|
|
def test_the_goodbye_for_a_refused_reliable_frame_does_not_acknowledge_it(self) -> None:
|
|
import dataclasses
|
|
|
|
from cultnet_py import CultNetRudpSendOptions, encode_rudp_packet
|
|
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
try:
|
|
sock, session = self._raw_peer(server)
|
|
# A reliable fragment whose metadata the session refuses: fragment id 0 names no set.
|
|
# The session records its sequence before it refuses it.
|
|
(fragment,) = session.send_many("schema", b"x", CultNetRudpSendOptions(reliable=True, ordered=True))
|
|
refused = dataclasses.replace(fragment, fragment_id=0, fragment_index=0, fragment_count=2)
|
|
sock.sendto(encode_rudp_packet(refused), ("127.0.0.1", server.port))
|
|
goodbye = None
|
|
for _ in range(40):
|
|
try:
|
|
wire, _ = sock.recvfrom(65535)
|
|
except TimeoutError:
|
|
continue
|
|
packet = decode_rudp_packet(wire)
|
|
if packet.packet_type == CultNetRudpPacketType.DISCONNECT:
|
|
goodbye = packet
|
|
break
|
|
self.assertIsNotNone(goodbye)
|
|
self.assertEqual(goodbye.payload, b"session refused a packet")
|
|
distance = goodbye.ack - refused.sequence
|
|
acknowledged = distance == 0 or (0 < distance <= 32 and goodbye.ack_mask & (1 << (distance - 1)) != 0)
|
|
self.assertFalse(acknowledged, "the goodbye acknowledged the refused frame")
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_responses_after_one_that_can_never_be_sent_are_not_sent(self) -> None:
|
|
from cultnet_py import CultNetRudpSendOptions, encode_rudp_packet
|
|
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
original_sendto = socket.socket.sendto
|
|
try:
|
|
sock, session = self._raw_peer(server)
|
|
addr = sock.getsockname()[:2]
|
|
request = hello(runtime_id="probe").to_bytes()
|
|
options = CultNetRudpSendOptions(reliable=True, ordered=True)
|
|
first = session.send("schema", request, options)
|
|
second = session.send("schema", request, options)
|
|
attempts: list[CultNetRudpPacketType] = []
|
|
failures = [1]
|
|
|
|
def sendto(sock_: socket.socket, data: bytes, *args: Any) -> int:
|
|
if args and tuple(args[-1]) == tuple(addr):
|
|
packet_type = decode_rudp_packet(data).packet_type
|
|
attempts.append(packet_type)
|
|
if packet_type == CultNetRudpPacketType.DATA and failures[0] > 0:
|
|
failures[0] -= 1
|
|
raise OSError(errno.EMSGSIZE, "Message too long")
|
|
return original_sendto(sock_, data, *args)
|
|
|
|
with patch.object(socket.socket, "sendto", sendto):
|
|
# The second frame waits for the first, so one receive delivers both, and
|
|
# the first of the two responses can never be sent as built.
|
|
sock.sendto(encode_rudp_packet(second), ("127.0.0.1", server.port))
|
|
time.sleep(0.1)
|
|
attempts.clear()
|
|
sock.sendto(encode_rudp_packet(first), ("127.0.0.1", server.port))
|
|
self._wait_for(lambda: CultNetRudpPacketType.DISCONNECT in attempts)
|
|
time.sleep(0.1)
|
|
|
|
# The unsendable response, then only the goodbye: no second response, no ack.
|
|
self.assertEqual(attempts, [CultNetRudpPacketType.DATA, CultNetRudpPacketType.DISCONNECT])
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_a_peer_ended_in_the_resend_loop_is_sent_nothing_more_from_it(self) -> None:
|
|
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
|
original_sendto = socket.socket.sendto
|
|
try:
|
|
x, x_addr = self._client(server, "peer-x")
|
|
mode = ["transient"]
|
|
attempts: list[CultNetRudpPacketType] = []
|
|
|
|
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
|
if args and tuple(args[-1]) == tuple(x_addr):
|
|
packet_type = decode_rudp_packet(data).packet_type
|
|
if mode[0] != "transient":
|
|
attempts.append(packet_type)
|
|
# Only responses fail; X's requests are acknowledged, so X goes quiet.
|
|
if packet_type == CultNetRudpPacketType.DATA and mode[0] == "transient":
|
|
raise OSError(errno.ENOBUFS, "injected transient failure")
|
|
if packet_type == CultNetRudpPacketType.DATA and mode[0] == "permanent":
|
|
mode[0] = "healthy"
|
|
raise OSError(errno.EMSGSIZE, "Message too long")
|
|
return original_sendto(sock, data, *args)
|
|
|
|
with patch.object(socket.socket, "sendto", sendto):
|
|
x.send("schema", hello(runtime_id="one").to_bytes())
|
|
x.send("schema", hello(runtime_id="two").to_bytes())
|
|
for _ in range(40):
|
|
x.receive_once()
|
|
time.sleep(0.005)
|
|
self._wait_for(lambda: server.rudp_send_failures >= 2)
|
|
# Both responses are owed; the next resend of the first can never be sent.
|
|
mode[0] = "permanent"
|
|
self._wait_for(lambda: CultNetRudpPacketType.DISCONNECT in attempts)
|
|
time.sleep(0.2)
|
|
|
|
self.assertEqual(attempts, [CultNetRudpPacketType.DATA, CultNetRudpPacketType.DISCONNECT])
|
|
self.assertTrue(server._rudp_thread.is_alive())
|
|
finally:
|
|
server.stop()
|
|
|
|
def _wait_for(self, predicate: Callable[[], bool]) -> None:
|
|
deadline = time.monotonic() + 2.0
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(0.005)
|
|
self.fail("condition not reached")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|