Add Python CultMesh local server
This commit is contained in:
parent
44870695b7
commit
27db7894b5
7 changed files with 279 additions and 1 deletions
|
|
@ -20,7 +20,11 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti.
|
|||
- Python CultMesh facade:
|
||||
Exposes C#-matching client entrypoints for Verse discovery, peer exchange,
|
||||
raw CultNet client construction, local nodes, catalogs, sessions, streams,
|
||||
authority leases, and simulation fact commits.
|
||||
authority leases, local TCP node serving, and simulation fact commits.
|
||||
- Python CultMesh local server:
|
||||
Serves a `CultMeshNode` plus Verse and peer catalogs over the same framed
|
||||
MessagePack request/response lane used by `CultNetRawClient` and
|
||||
`CultMeshDiscoveryClient`.
|
||||
- Python CultMesh reactive catalogs:
|
||||
Verse and peer catalogs expose sorted local views, `get(...)`, response
|
||||
application, and `watch(...)` callbacks for local discovery updates. Verse
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id="
|
|||
|
||||
peers = CultMesh.create_peer_catalog()
|
||||
response = peers.create_response(peer_exchange_request("pex-1", verse_id="local"))
|
||||
server = CultMesh.serve_node(node, peer_catalog=peers, port=3075)
|
||||
|
||||
client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075)
|
||||
verses = client.fetch_verses(transport_version="cultmesh.v0")
|
||||
|
|
@ -255,6 +256,7 @@ client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"
|
|||
raw_client = CultNetRawClient("127.0.0.1", 3075)
|
||||
node.database.sync_snapshot(raw_client, schema_ids=[note_doc.catalog_entry().schema_id])
|
||||
node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1)
|
||||
server.stop()
|
||||
|
||||
streams = CultMesh.create_stream_catalog()
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = {
|
|||
"CultMeshDiscoveryClient",
|
||||
"CultMeshGameSession",
|
||||
"CultMeshGameSessionOptions",
|
||||
"CultMeshLocalServer",
|
||||
"CultMeshNode",
|
||||
"CultMeshPeerCard",
|
||||
"CultMeshPeerCatalog",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from .client import CultMeshDiscoveryClient, CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient
|
||||
from .facade import CultMesh
|
||||
from .node import CultMeshDatabase, CultMeshDatabaseChange, CultMeshNode, create_node
|
||||
from .server import CultMeshLocalServer
|
||||
from .session import (
|
||||
CultMeshGameSession,
|
||||
CultMeshGameSessionOptions,
|
||||
|
|
@ -45,6 +46,7 @@ __all__ = [
|
|||
"CultMesh",
|
||||
"CultMeshGameSession",
|
||||
"CultMeshGameSessionOptions",
|
||||
"CultMeshLocalServer",
|
||||
"CultMeshPeerCard",
|
||||
"CultMeshPeerCatalog",
|
||||
"CultMeshPrediction",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from cultnet_py import CultNetRawClient
|
|||
|
||||
from .client import CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient
|
||||
from .node import CultMeshNode, create_node
|
||||
from .server import CultMeshLocalServer
|
||||
from .session import CultMeshGameSession, CultMeshGameSessionOptions
|
||||
from .simulation import CultMeshSimulationFactCommitter
|
||||
from .wire import (
|
||||
|
|
@ -70,6 +71,25 @@ class CultMesh:
|
|||
) -> CultMeshGameSession:
|
||||
return CultMeshGameSession(node, options)
|
||||
|
||||
@staticmethod
|
||||
def serve_node(
|
||||
node: CultMeshNode,
|
||||
*,
|
||||
verse_catalog: CultMeshVerseCatalog | None = None,
|
||||
peer_catalog: CultMeshPeerCatalog | None = None,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
display_name: str | None = None,
|
||||
) -> CultMeshLocalServer:
|
||||
return CultMeshLocalServer(
|
||||
node=node,
|
||||
verse_catalog=verse_catalog or CultMeshVerseCatalog(),
|
||||
peer_catalog=peer_catalog or CultMeshPeerCatalog(),
|
||||
host=host,
|
||||
port=port,
|
||||
display_name=display_name,
|
||||
).start()
|
||||
|
||||
@staticmethod
|
||||
def create_client(
|
||||
host: str = "localhost",
|
||||
|
|
|
|||
197
packages/cultcache-py/src/cultmesh_py/server.py
Normal file
197
packages/cultcache-py/src/cultmesh_py/server.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import msgpack
|
||||
|
||||
from cultnet_py import read_frame, write_frame
|
||||
|
||||
from .node import CultMeshNode
|
||||
from .wire import (
|
||||
PEER_EXCHANGE_REQUEST,
|
||||
VERSE_CATALOG_REQUEST,
|
||||
CultMeshPeerCatalog,
|
||||
CultMeshVerseCatalog,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CultMeshLocalServer:
|
||||
node: CultMeshNode
|
||||
verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog)
|
||||
peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog)
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 0
|
||||
display_name: str | None = None
|
||||
runtime_kind: str = "python"
|
||||
_socket: socket.socket | None = field(default=None, init=False, repr=False)
|
||||
_stop: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
|
||||
_thread: threading.Thread | None = field(default=None, init=False, repr=False)
|
||||
|
||||
def start(self) -> "CultMeshLocalServer":
|
||||
if self._socket is not None:
|
||||
return self
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((self.host, self.port))
|
||||
server.listen()
|
||||
server.settimeout(0.2)
|
||||
self._socket = server
|
||||
self.port = int(server.getsockname()[1])
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._socket is not None:
|
||||
try:
|
||||
self._socket.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._socket = None
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
def __enter__(self) -> "CultMeshLocalServer":
|
||||
return self.start()
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
|
||||
self.stop()
|
||||
|
||||
def handle_message(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
||||
schema_version = message.get("schemaVersion")
|
||||
if schema_version == "cultnet.hello.v0":
|
||||
return self._hello_response()
|
||||
if schema_version == "cultnet.schema_catalog_request.v0":
|
||||
return self._schema_catalog_response(message)
|
||||
if schema_version == "cultnet.snapshot_request.v0":
|
||||
return self.node.database.create_snapshot_response(
|
||||
message_id=str(message.get("messageId") or ""),
|
||||
schema_ids=[str(value) for value in message.get("schemaIds") or []],
|
||||
record_keys=[str(value) for value in message.get("recordKeys") or []],
|
||||
shard_id=message.get("shardId"),
|
||||
shard_epoch=message.get("shardEpoch"),
|
||||
)
|
||||
if schema_version == "cultnet.shard_catalog_request.v0":
|
||||
return self._shard_catalog_response(message)
|
||||
if schema_version == "cultnet.shard_log_request.v0":
|
||||
return self.node.database.create_shard_log_response(
|
||||
message_id=str(message.get("messageId") or ""),
|
||||
shard_id=str(message.get("shardId") or ""),
|
||||
shard_epoch=message.get("shardEpoch"),
|
||||
after_sequence=int(message.get("afterSequence") or 0),
|
||||
limit=message.get("limit"),
|
||||
)
|
||||
if schema_version == VERSE_CATALOG_REQUEST:
|
||||
return self.verse_catalog.create_response(message)
|
||||
if schema_version == PEER_EXCHANGE_REQUEST:
|
||||
return self.peer_catalog.create_response(message)
|
||||
return None
|
||||
|
||||
def _accept_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
server = self._socket
|
||||
if server is None:
|
||||
return
|
||||
try:
|
||||
client, _ = server.accept()
|
||||
except TimeoutError:
|
||||
continue
|
||||
except OSError:
|
||||
return
|
||||
threading.Thread(target=self._handle_connection, args=(client,), daemon=True).start()
|
||||
|
||||
def _handle_connection(self, client: socket.socket) -> None:
|
||||
with client:
|
||||
stream = client.makefile("rwb")
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
message = msgpack.unpackb(read_frame(stream), raw=False)
|
||||
except EOFError:
|
||||
return
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
response = self.handle_message(message)
|
||||
if response is None:
|
||||
continue
|
||||
write_frame(stream, msgpack.packb(response, use_bin_type=True))
|
||||
stream.flush()
|
||||
|
||||
def _hello_response(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schemaVersion": "cultnet.hello.v0",
|
||||
"runtimeId": self.node.runtime_id,
|
||||
"runtimeKind": self.runtime_kind,
|
||||
"displayName": self.display_name or self.node.runtime_id,
|
||||
"supportedDocumentTypes": [document.type for document in self.node.documents],
|
||||
"supportedMessageVersions": [
|
||||
"cultnet.hello.v0",
|
||||
"cultnet.schema_catalog_request.v0",
|
||||
"cultnet.snapshot_request.v0",
|
||||
"cultnet.shard_catalog_request.v0",
|
||||
"cultnet.shard_log_request.v0",
|
||||
VERSE_CATALOG_REQUEST,
|
||||
PEER_EXCHANGE_REQUEST,
|
||||
],
|
||||
"supportsSchemaCatalog": True,
|
||||
}
|
||||
|
||||
def _schema_catalog_response(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
requested_schema_ids = {str(value) for value in request.get("schemaIds") or []}
|
||||
requested_kinds = {str(value) for value in request.get("kinds") or []}
|
||||
include_schema_json = request.get("includeSchemaJson") is True
|
||||
schemas = []
|
||||
for document in self.node.documents:
|
||||
entry = document.catalog_entry()
|
||||
if requested_schema_ids and entry.schema_id not in requested_schema_ids:
|
||||
continue
|
||||
if requested_kinds and "document_payload" not in requested_kinds:
|
||||
continue
|
||||
descriptor = {
|
||||
"schemaId": entry.schema_id,
|
||||
"kind": "document_payload",
|
||||
"schemaVersion": entry.schema_version,
|
||||
"documentType": document.type,
|
||||
"title": entry.schema_name,
|
||||
"wireContracts": ["cultnet.document_put_raw.v0", "cultnet.snapshot_response_raw.v0"],
|
||||
"contentHash": entry.content_hash,
|
||||
}
|
||||
if include_schema_json:
|
||||
descriptor["schemaJson"] = entry.canonical_schema_json
|
||||
schemas.append(descriptor)
|
||||
return {
|
||||
"schemaVersion": "cultnet.schema_catalog_response.v0",
|
||||
"messageId": request.get("messageId", ""),
|
||||
"schemas": schemas,
|
||||
}
|
||||
|
||||
def _shard_catalog_response(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
requested_schema_ids = {str(value) for value in request.get("schemaIds") or []}
|
||||
schema_ids = [document.catalog_entry().schema_id for document in self.node.documents]
|
||||
if requested_schema_ids:
|
||||
schema_ids = [schema_id for schema_id in schema_ids if schema_id in requested_schema_ids]
|
||||
shards = []
|
||||
if schema_ids:
|
||||
shards.append({
|
||||
"shardId": "primary",
|
||||
"ownerRuntimeId": self.node.runtime_id,
|
||||
"epoch": 0,
|
||||
"isPrimary": True,
|
||||
"schemaIds": schema_ids,
|
||||
"keyPrefix": "",
|
||||
"primaryEndpoints": [f"cultnet://{self.host}:{self.port}"],
|
||||
"replicaEndpoints": [],
|
||||
"readReplicaEndpoints": [f"cultnet://{self.host}:{self.port}"],
|
||||
"region": "local",
|
||||
})
|
||||
return {
|
||||
"schemaVersion": "cultnet.shard_catalog_response.v0",
|
||||
"messageId": request.get("messageId", ""),
|
||||
"shards": shards,
|
||||
}
|
||||
|
|
@ -1624,6 +1624,58 @@ class CultCacheTests(unittest.TestCase):
|
|||
self.assertEqual(local_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a")
|
||||
self.assertEqual(synced_peers[0].roles, ("read-replica",))
|
||||
|
||||
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="primary", shard_epoch=1)
|
||||
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, 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)
|
||||
snapshot_response = raw_client.fetch_snapshot(schema_ids=["mesh.server_note.v1"])
|
||||
shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.server_note.v1"])
|
||||
shard_log = raw_client.fetch_shard_log(shard_id="primary", shard_epoch=1)
|
||||
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"])
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
self.assertEqual(hello_response["runtimeId"], "mesh-server")
|
||||
self.assertEqual(hello_response["displayName"], "Mesh Server")
|
||||
self.assertEqual(schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1")
|
||||
self.assertIn("schemaJson", schema_response["schemas"][0])
|
||||
self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1")
|
||||
self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["mesh.server_note.v1"])
|
||||
self.assertEqual(shard_log["entries"][0]["changeKind"], "added")
|
||||
self.assertEqual(fetched_verses[0].verse_id, "server-verse")
|
||||
self.assertEqual(fetched_peers[0].peer_id, "mesh-server")
|
||||
|
||||
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
|
||||
peer = CultMeshPeerCard(
|
||||
peer_id="voidbot-local",
|
||||
|
|
|
|||
Loading…
Reference in a new issue