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-codewkt/big_boi_docs_472_follow_ups
parent
095c165a34
commit
adf71d74e8
|
|
@ -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
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue