Add a dedicated-registrar example + discovery guide

Add a runnable `examples/dedicated_registrar.py` + a "A dedicated
registrar" subsection in `guide/discovery.rst` demoing the
registrar decoupled from any app tree's root: boot a bare
`tractor.run_daemon([], registry_addrs=[...])` as its own process
(a root actor that does nothing but hold the registry), point the
app tree at the same `registry_addrs`, and discover a service
*through* that external registrar.

This is the buildable-today form of the #472
"Registrar-as-subsystem (not the root actor)" bullet. Two
constraints are called out inline as follow-ups:
`enable_transports` is single-proto per runtime (no multi-backend
registrar yet), and a registrar can only be a root (no `actor_cls`
hook on `start_actor()` to spawn one as a subactor).

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
wkt/big_boi_docs_472_follow_ups
Gud Boi 2026-07-02 12:32:49 -04:00
parent 095c165a34
commit adf71d74e8
2 changed files with 144 additions and 0 deletions

View File

@ -62,6 +62,28 @@ the one-and-only registrar; boot then fails loudly with a
``RuntimeError`` if some other process already bound the registry ``RuntimeError`` if some other process already bound the registry
socket(s). 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 Looking up actors
----------------- -----------------

View File

@ -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()