Add Python CultMesh daemon entrypoint
This commit is contained in:
parent
31cf0377bc
commit
cf151fa67a
7 changed files with 186 additions and 3 deletions
|
|
@ -97,7 +97,10 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti.
|
|||
raw CultNet client construction, local nodes, catalogs, sessions, streams,
|
||||
authority leases, local TCP node serving, simulation fact commits, and the
|
||||
durable shard-log node option that attaches a file-backed authoritative log
|
||||
store beside the cache file by default.
|
||||
store beside the cache file by default. The `cultmesh-py-daemon` /
|
||||
`python -m cultmesh_py.daemon` entrypoint launches that same local TCP server
|
||||
body and emits a bounded readiness document for process supervisors while the
|
||||
served CultNet endpoint remains the capability owner.
|
||||
- Python CultMesh authority leases:
|
||||
Lease objects own validity and peer/role/shard coverage checks; the catalog
|
||||
owns sorted listing, lookup, upsert, signature verification policy, and
|
||||
|
|
@ -215,8 +218,8 @@ node --test packages\cultnet-ts\dist-test\test\interop\cultnet-interop.test.js
|
|||
- Python does not own LiteNetLib security/session behavior; it speaks the
|
||||
schema-v0 interop lane used by the package harness.
|
||||
- Python does not implement the full C# daemon/server stack. Its current role is
|
||||
package runtime, raw interop peer, local CultMesh session facade, and typed
|
||||
state participant.
|
||||
package runtime, raw interop peer, launchable local CultMesh endpoint, local
|
||||
CultMesh session facade, and typed state participant.
|
||||
- Python does not claim the full C# shard mutation-log service surface. It can
|
||||
create/apply raw snapshots, create/apply package-local raw shard-log
|
||||
responses, consume remote shard-log responses, and run pull-once replica
|
||||
|
|
|
|||
|
|
@ -309,6 +309,19 @@ server = CultMesh.serve_node(
|
|||
max_snapshot_documents=1000,
|
||||
max_snapshot_bytes=4 * 1024 * 1024,
|
||||
)
|
||||
```
|
||||
|
||||
The same local server can be launched as a package daemon when another runtime
|
||||
or operator process needs a CultNet endpoint without embedding Python glue:
|
||||
|
||||
```powershell
|
||||
python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --ready-file mesh.ready.json
|
||||
```
|
||||
|
||||
The ready file and stdout line are process readiness hints. The served state and
|
||||
capability truth still live on the framed CultNet/CultMesh endpoint.
|
||||
|
||||
```python
|
||||
|
||||
client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075)
|
||||
verses = client.fetch_verses(transport_version="cultmesh.v0")
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ cultnet-py-interop = "cultnet_py.interop_peer:main"
|
|||
cultcache-py-benchmark = "cultcache_py.benchmark:main"
|
||||
cultcache-py-compare-csharp = "cultcache_py.compare_csharp:main"
|
||||
cultcache-py-verify = "cultcache_py.verify:main"
|
||||
cultmesh-py-daemon = "cultmesh_py.daemon:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
|
|||
|
|
@ -121,11 +121,13 @@ EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = {
|
|||
"CultMeshVerseDiscoveryClient",
|
||||
"PEER_EXCHANGE_REQUEST",
|
||||
"PEER_EXCHANGE_RESPONSE",
|
||||
"READY_SCHEMA_VERSION",
|
||||
"SIMULATION_FACT_DOCUMENT_TYPE",
|
||||
"SIMULATION_FACT_SCHEMA_VERSION",
|
||||
"VERSE_CATALOG_REQUEST",
|
||||
"VERSE_CATALOG_RESPONSE",
|
||||
"create_node",
|
||||
"daemon_ready_document",
|
||||
"peer_exchange_request",
|
||||
"peer_from_wire",
|
||||
"simulation_fact_document",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from .client import (
|
|||
CultMeshSnapshotFanout,
|
||||
CultMeshVerseDiscoveryClient,
|
||||
)
|
||||
from .daemon import READY_SCHEMA_VERSION, daemon_ready_document
|
||||
from .facade import CultMesh
|
||||
from .node import CultMeshDatabase, CultMeshDatabaseChange, CultMeshNode, CultMeshNodeOptions, create_node
|
||||
from .server import CultMeshLocalServer
|
||||
|
|
@ -84,6 +85,7 @@ __all__ = [
|
|||
"CultMeshVerseCatalog",
|
||||
"CultMeshVerseCompatibility",
|
||||
"CultMeshVerseDescriptor",
|
||||
"READY_SCHEMA_VERSION",
|
||||
"PEER_EXCHANGE_REQUEST",
|
||||
"PEER_EXCHANGE_RESPONSE",
|
||||
"SIMULATION_FACT_DOCUMENT_TYPE",
|
||||
|
|
@ -91,6 +93,7 @@ __all__ = [
|
|||
"VERSE_CATALOG_REQUEST",
|
||||
"VERSE_CATALOG_RESPONSE",
|
||||
"create_node",
|
||||
"daemon_ready_document",
|
||||
"peer_from_wire",
|
||||
"peer_exchange_request",
|
||||
"simulation_fact_document",
|
||||
|
|
|
|||
95
packages/cultcache-py/src/cultmesh_py/daemon.py
Normal file
95
packages/cultcache-py/src/cultmesh_py/daemon.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .facade import CultMesh
|
||||
from .server import CultMeshLocalServer
|
||||
|
||||
READY_SCHEMA_VERSION = "cultmesh.daemon_ready.v0"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="cultmesh-py-daemon")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=3075)
|
||||
parser.add_argument("--runtime-id", default="python-runtime")
|
||||
parser.add_argument("--display-name")
|
||||
parser.add_argument("--cache-file")
|
||||
parser.add_argument("--enable-durable-shard-logs", action="store_true")
|
||||
parser.add_argument("--shard-log-file")
|
||||
parser.add_argument("--max-snapshot-documents", type=int)
|
||||
parser.add_argument("--max-snapshot-bytes", type=int)
|
||||
parser.add_argument("--ready-file")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
stop = threading.Event()
|
||||
install_signal_handlers(stop)
|
||||
server = start_server(args)
|
||||
try:
|
||||
ready = daemon_ready_document(server)
|
||||
publish_ready(ready, ready_file=args.ready_file)
|
||||
while not stop.wait(0.2):
|
||||
pass
|
||||
finally:
|
||||
server.stop()
|
||||
return 0
|
||||
|
||||
|
||||
def start_server(args: argparse.Namespace) -> CultMeshLocalServer:
|
||||
node = CultMesh.create_node(
|
||||
args.cache_file,
|
||||
runtime_id=args.runtime_id,
|
||||
enable_durable_shard_logs=args.enable_durable_shard_logs,
|
||||
shard_log_path=args.shard_log_file,
|
||||
)
|
||||
return CultMesh.serve_node(
|
||||
node,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
display_name=args.display_name,
|
||||
max_snapshot_documents=args.max_snapshot_documents,
|
||||
max_snapshot_bytes=args.max_snapshot_bytes,
|
||||
)
|
||||
|
||||
|
||||
def daemon_ready_document(server: CultMeshLocalServer) -> dict[str, Any]:
|
||||
return {
|
||||
"schemaVersion": READY_SCHEMA_VERSION,
|
||||
"runtimeId": server.node.runtime_id,
|
||||
"runtimeKind": server.runtime_kind,
|
||||
"host": server.host,
|
||||
"port": server.port,
|
||||
"endpoint": f"cultnet://{server.host}:{server.port}",
|
||||
"supportedDocumentTypes": [document.type for document in server.node.documents],
|
||||
}
|
||||
|
||||
|
||||
def publish_ready(document: dict[str, Any], *, ready_file: str | None = None) -> None:
|
||||
text = json.dumps(document, separators=(",", ":"), sort_keys=True)
|
||||
if ready_file:
|
||||
Path(ready_file).write_text(text, encoding="utf-8")
|
||||
print(text, flush=True)
|
||||
|
||||
|
||||
def install_signal_handlers(stop: threading.Event) -> None:
|
||||
def request_stop(signum: int, frame: Any) -> None:
|
||||
stop.set()
|
||||
|
||||
for signal_name in ("SIGINT", "SIGTERM"):
|
||||
signal_value = getattr(signal, signal_name, None)
|
||||
if signal_value is None:
|
||||
continue
|
||||
try:
|
||||
signal.signal(signal_value, request_stop)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -108,6 +111,7 @@ from cultmesh_py import (
|
|||
CultMeshVerseCompatibility,
|
||||
CultMeshVerseDescriptor,
|
||||
CultMeshVerseDiscoveryClient,
|
||||
READY_SCHEMA_VERSION,
|
||||
peer_exchange_request,
|
||||
simulation_fact_document,
|
||||
verse_catalog_request,
|
||||
|
|
@ -3015,6 +3019,68 @@ class CultCacheTests(unittest.TestCase):
|
|||
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),
|
||||
],
|
||||
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["endpoint"], f"cultnet://127.0.0.1:{ready['port']}")
|
||||
|
||||
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"])
|
||||
finally:
|
||||
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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue