278 lines
9.9 KiB
ReStructuredText
278 lines
9.9 KiB
ReStructuredText
Actor discovery
|
|
===============
|
|
|
|
So you've spawned a tree of trio-"actors"; now their tasks need to
|
|
*find* each other to start a dialog. ``tractor`` ships a (self
|
|
admittedly) **very naive** discovery system which is nonetheless
|
|
mighty handy for wiring up service-style apps: a built-in
|
|
*registrar* actor plus a small set of lookup APIs that deliver
|
|
a live, connected ``Portal`` to whichever peer you're after.
|
|
|
|
.. d2:: diagrams/actor_tree.d2
|
|
:margin:
|
|
:caption: The root actor doubles as the *registrar* by
|
|
default; every spawned actor registers itself with it.
|
|
:alt: actor tree with root acting as registrar
|
|
|
|
Because ``tractor`` is built on structured concurrency (SC), the
|
|
discovery layer is *not* some external etcd/consul-shaped service
|
|
you have to babysit; it's just another actor — normally the root
|
|
of your tree — doing a bit of bookkeeping as part of the runtime.
|
|
|
|
Every actor phones home
|
|
-----------------------
|
|
|
|
On runtime boot **every** actor self-registers with the registrar:
|
|
it submits its unique ``(name, uuid)`` identity pair (aka its
|
|
``uid``) mapped to the list of transport addresses its IPC server
|
|
is bound to. On graceful teardown it likewise *un*-registers, so
|
|
the registry tracks the live tree as it grows and shrinks.
|
|
|
|
.. note::
|
|
Actor names are **not** enforced unique — the registry is keyed
|
|
by the full ``(name, uuid)`` pair. Name-based lookups simply
|
|
resolve to the *last* registered match, so if you boot five
|
|
actors all named ``'bob'``, you get the freshest ``'bob'`` B)
|
|
|
|
First boot: who's the registrar?
|
|
--------------------------------
|
|
|
|
By default the **root actor** *is* the registrar; subactors
|
|
inherit the tree's ``registry_addrs`` at spawn time so the whole
|
|
clan shares one registry with zero config on your part.
|
|
|
|
The bootstrap rule inside ``open_root_actor()`` is delightfully
|
|
simple:
|
|
|
|
- on boot, ping every socket addr in ``registry_addrs``; when none
|
|
are passed the per-transport defaults are used: for TCP the
|
|
loopback ``('127.0.0.1', 1616)``, for UDS a
|
|
``registry@1616.sock`` file,
|
|
|
|
- if a registrar answers, you boot as a plain (non-registrar) root
|
|
actor and register with the *existing* registry; your own IPC
|
|
server binds random same-transport addrs instead,
|
|
|
|
- if **nothing answers, congratulations: you just became the
|
|
registrar**. Your transport server binds the registry addrs
|
|
themselves and you start serving lookups for everyone else.
|
|
|
|
Pass ``ensure_registry=True`` when your program *requires* being
|
|
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
|
|
-----------------
|
|
|
|
All lookup APIs are async context managers, so the SC rule you
|
|
already know from the rest of ``tractor`` holds here too: any
|
|
delivered portal (and its underlying IPC channel) is scoped to
|
|
your ``async with`` block — no dangling connections.
|
|
|
|
``find_actor()``
|
|
****************
|
|
|
|
The workhorse: ask the registrar for ``name`` and connect a portal
|
|
to the match, or get ``None`` back when nobody's home:
|
|
|
|
.. code:: python
|
|
|
|
async with tractor.find_actor('data_feed') as portal:
|
|
if portal is None:
|
|
... # not registered anywhere; maybe spawn it?
|
|
else:
|
|
await portal.run(do_stuff)
|
|
|
|
Knobs worth knowing:
|
|
|
|
- ``registry_addrs=[...]``: query specific (possibly multiple,
|
|
possibly remote) registrars instead of your tree's default,
|
|
|
|
- ``only_first=False``: deliver a ``list[Portal]`` of *all*
|
|
matches found across the queried registrars instead of just the
|
|
first,
|
|
|
|
- ``raise_on_none=True``: raise a ``RuntimeError`` instead of
|
|
yielding ``None`` when no match is found — for when absence is
|
|
a hard error in your app.
|
|
|
|
``wait_for_actor()``
|
|
********************
|
|
|
|
Blocks until *someone* registers under ``name``, then yields a
|
|
portal to that registree. Perfect for "wait for my sibling service
|
|
to come up" sequencing:
|
|
|
|
.. code:: python
|
|
|
|
async with tractor.wait_for_actor('service') as portal:
|
|
await portal.run(some_fn)
|
|
|
|
``query_actor()``
|
|
*****************
|
|
|
|
A lookup *without* connecting to the target: yields an
|
|
``(addr, reg_portal)`` pair where ``addr`` is the peer's preferred
|
|
transport address, or ``None`` when nothing is registered under
|
|
that name. Use it for liveness peeks or to log where a service
|
|
lives without actually dialing it up.
|
|
|
|
``get_registry()``
|
|
******************
|
|
|
|
Yields a portal straight to the registrar actor itself — or a
|
|
``LocalPortal`` shim when the calling actor *is* the registrar
|
|
(no IPC required to talk to yourself, hopefully).
|
|
|
|
Fast paths and address preference
|
|
---------------------------------
|
|
|
|
Before doing any RPC to the registrar, every lookup first scans
|
|
the calling actor's *already-connected peers*: if you have a live
|
|
channel to an actor named ``name`` you get a portal over it
|
|
immediately, no registrar round-trip at all.
|
|
|
|
When a registry entry holds *multiple* addresses (a multihomed
|
|
actor) the "best" one is chosen by locality:
|
|
|
|
1. UDS — same-host guaranteed, lowest overhead,
|
|
|
|
2. local TCP — loopback or any of this host's own interface
|
|
addrs,
|
|
|
|
3. remote TCP — the only option when actually distributed.
|
|
|
|
Within a tier the most recently registered addr wins. Stale
|
|
entries (an addr that no longer accepts connections) are detected
|
|
on use and deleted from the registrar's table on your behalf.
|
|
|
|
Demo: register and find a service
|
|
---------------------------------
|
|
|
|
The simplest possible spin: start a subactor, ask the registrar
|
|
where it lives, and wait on its registration:
|
|
|
|
.. literalinclude:: ../../examples/service_discovery.py
|
|
:caption: examples/service_discovery.py
|
|
:language: python
|
|
|
|
The daemon-service pattern
|
|
--------------------------
|
|
|
|
The classic deployment shape: a long-lived daemon actor serves
|
|
RPC, later-running code discovers it by name, calls in, and
|
|
gracefully cancels it when the job is done:
|
|
|
|
.. literalinclude:: ../../examples/service_daemon_discovery.py
|
|
:caption: examples/service_daemon_discovery.py
|
|
:language: python
|
|
|
|
Note the teardown ordering — *graceful cancel* of the daemon via
|
|
its portal is part of the pattern; under SC a "service" is still
|
|
somebody's child and somebody is responsible for reaping it.
|
|
|
|
Joining an existing tree from outside
|
|
-------------------------------------
|
|
|
|
Discovery isn't limited to a single program: any standalone script
|
|
can join a running tree by booting its *own* root actor pointed at
|
|
the existing registrar:
|
|
|
|
.. code:: python
|
|
|
|
import trio
|
|
import tractor
|
|
|
|
async def main():
|
|
async with (
|
|
# contact the live tree's registrar
|
|
tractor.open_root_actor(
|
|
registry_addrs=[('127.0.0.1', 1616)],
|
|
),
|
|
tractor.find_actor('data_feed') as portal,
|
|
):
|
|
... # RPC away like you were born here
|
|
|
|
trio.run(main)
|
|
|
|
Per the bootstrap rules above, if the registrar at those addrs is
|
|
*not* reachable this process simply becomes its own (registrar)
|
|
root — so the same code works standalone and as a tree-joiner.
|
|
|
|
"Arbiter"? A legacy naming note
|
|
-------------------------------
|
|
|
|
In older releases (and many an old blog post or issue thread) the
|
|
registrar actor was called the *arbiter*, with matching APIs like
|
|
``get_arbiter()`` and an ``arbiter_addr`` argument. All of that
|
|
terminology is retired: it's *registrar*/*registry* everywhere now
|
|
(``registry_addrs``, ``get_registry()``, ...) and the
|
|
``tractor.Arbiter`` export survives only as a back-compat alias of
|
|
``tractor.Registrar``. If you see "arbiter" somewhere, mentally
|
|
substitute "registrar" and you're up to date.
|
|
|
|
.. note::
|
|
Multihoming nerds: ``tractor.discovery`` also ships
|
|
libp2p-style *multiaddr* helpers — ``mk_maddr()`` and
|
|
``parse_maddr()`` — for describing transport endpoints as
|
|
structured strings.
|
|
|
|
Very naive, very honest
|
|
-----------------------
|
|
|
|
To be clear, this is a **very naive** discovery system: one
|
|
process-tree-local registrar holding a dict, no replication, no
|
|
re-election when it dies, no cross-host propagation. That's
|
|
intentional (for now); it covers the "wire up my services on this
|
|
host" case without dragging in a consensus protocol.
|
|
|
|
On the roadmap (issue `#216`_ tracks a chunk of it):
|
|
|
|
- registrar high(er)-availability: staying up past tree teardown
|
|
and re-election,
|
|
|
|
- a `gossip protocol`_ for decentralized cross-host discovery (the
|
|
zguide's `discovery`_ chapter is the spiritual reference),
|
|
|
|
- `modern protocol`_ (rendezvous) style meet-up points.
|
|
|
|
If any of that scratches your itch, the issue tracker would love
|
|
to hear from you.
|
|
|
|
.. seealso::
|
|
- :doc:`/guide/testing` — watching live actor trees (and their
|
|
registrar) while the test suite or your app runs.
|
|
- API refs: :func:`tractor.find_actor`,
|
|
:func:`tractor.wait_for_actor`,
|
|
:func:`tractor.query_actor`,
|
|
:func:`tractor.get_registry`,
|
|
:class:`tractor.Registrar`.
|
|
|
|
.. _gossip protocol: https://en.wikipedia.org/wiki/Gossip_protocol
|
|
.. _modern protocol: https://en.wikipedia.org/wiki/Rendezvous_protocol
|
|
.. _discovery: https://zguide.zeromq.org/docs/chapter8/#Discovery
|
|
.. _#216: https://github.com/goodboy/tractor/issues/216
|