diff --git a/docs/guide/discovery.rst b/docs/guide/discovery.rst index cc76a7f3..811b38e1 100644 --- a/docs/guide/discovery.rst +++ b/docs/guide/discovery.rst @@ -62,6 +62,28 @@ the one-and-only registrar; boot then fails loudly with a ``RuntimeError`` if some other process already bound the registry socket(s). +A dedicated registrar +--------------------- +That second rule — *"if a registrar answers, boot as a plain +root"* — is all you need to run the registry as its own +**standalone process**, decoupled from any app tree's root. Boot +a bare ``tractor.run_daemon([], registry_addrs=[...])`` (a root +actor that does nothing but hold the registry), point your app +tree at the same ``registry_addrs``, and every actor discovers +through that *external* registrar instead of a tree-local one: + +.. literalinclude:: ../../examples/dedicated_registrar.py + :caption: examples/dedicated_registrar.py + :language: python + +This is the "registrar as a subsystem, not the root actor" shape. +Two caveats today (both tracked as #472 follow-ups): +``enable_transports`` is single-proto per runtime, so a registrar +can't yet serve multiple backends at once; and there's no way to +spawn a registrar as a *sub*-actor of a shared tree (only as its +own root), since ``start_actor()`` has no custom-``actor_cls`` +hook. + Looking up actors ----------------- diff --git a/examples/dedicated_registrar.py b/examples/dedicated_registrar.py new file mode 100644 index 00000000..d80af49b --- /dev/null +++ b/examples/dedicated_registrar.py @@ -0,0 +1,122 @@ +''' +Run a *dedicated* registrar as its own standalone process — decoupled +from your app's root actor — and discover a service *through* it. + +Normally the registrar **is** the root actor of your tree. Here we +instead boot a separate `tractor.run_daemon([], registry_addrs=[...])` +process whose *sole* job is to be the registry, then point our app +tree at it via `registry_addrs`. Because a registrar is already +reachable at that addr, our app's root actor does NOT become one — it +registers with (and discovers through) the external daemon. That's the +"registrar as a subsystem, not the root actor" pattern. + +NB: `enable_transports` is single-proto per-runtime today (see +`tractor._root`), so this demos one transport; a genuinely +multi-backend registrar (and spawning one as a *sub*actor of a shared +tree) are future runtime work — see the #472 follow-ups. + +''' +from contextlib import suppress +import signal +import socket +import subprocess +import sys +import time + +import trio +import tractor + + +# the fixed addr the dedicated registrar binds and everyone points at. +REG_ADDR: tuple[str, int] = ('127.0.0.1', 1717) + + +def _wait_registrar_ready( + addr: tuple[str, int], + proc: subprocess.Popen, + deadline: float = 10.0, +) -> None: + ''' + Active-poll the registrar's bind addr until it accepts a + connection (proving it's booted + listening), bailing early if + the daemon proc dies during startup. + + ''' + end: float = time.monotonic() + deadline + while time.monotonic() < end: + if proc.poll() is not None: + raise RuntimeError( + f'registrar died on startup (rc={proc.returncode})' + ) + with suppress(OSError): + with socket.create_connection(addr, timeout=0.1): + return + time.sleep(0.05) + raise TimeoutError(f'registrar never came up @ {addr}') + + +async def greet() -> str: + '''A trivial service task any peer can RPC by name.''' + return f'hello from {tractor.current_actor().name}!' + + +async def app() -> None: + ''' + Point our app tree at the EXTERNAL registrar (not its own root) + via `registry_addrs`, register a named service, then discover + + RPC it purely by name. + + ''' + an: tractor.ActorNursery + async with tractor.open_nursery( + registry_addrs=[REG_ADDR], + enable_transports=['tcp'], + ) as an: + # this subactor registers with the DEDICATED registrar @ + # REG_ADDR (our root is a plain peer, not the registry). + await an.start_actor( + 'greeter', + enable_modules=[__name__], + ) + # discover it *through the external registrar*, by name only. + portal: tractor.Portal + async with tractor.wait_for_actor('greeter') as portal: + print(f'found `greeter` via dedicated registrar @ {REG_ADDR}') + print(await portal.run(greet)) + await an.cancel() + + +def main() -> None: + # boot the dedicated registrar as its own process/tree: an empty + # `enable_modules` `run_daemon()` is just a root actor that does + # nothing but hold + serve the registry. + code: str = ( + 'import tractor; ' + f'tractor.run_daemon([], registry_addrs={[REG_ADDR]!r}, ' + "enable_transports=['tcp'], loglevel='error')" + ) + registrar: subprocess.Popen = subprocess.Popen( + [sys.executable, '-c', code], + # the registry is a quiet background service; hush its logs + + # expected SIGINT-teardown traceback so the demo output stays + # focused on the discovery flow. + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + _wait_registrar_ready(REG_ADDR, registrar) + print( + f'dedicated registrar up @ {REG_ADDR} ' + f'(pid {registrar.pid})' + ) + trio.run(app) + finally: + # graceful SIGINT teardown of the standalone registrar. + registrar.send_signal(signal.SIGINT) + with suppress(subprocess.TimeoutExpired): + registrar.wait(timeout=10) + print('dedicated registrar shut down') + + +if __name__ == '__main__': + main()