|
1 | 1 | # Remote Registry |
| 2 | + |
| 3 | +The `hyperactor::actor::remote` module provides the process-local registry for remote-spawnable actors. It is the counterpart to `RemoteSpawn`: given actor types that implement `RemoteSpawn` and are registered with `remote!`, this module discovers them at runtime and allows actors to be spawned by their global type name. The implementation uses the [`inventory`](https://docs.rs/inventory/0.3.21/inventory/index.html) crate to collect registrations contributed from any crate linked into the application. |
| 4 | + |
| 5 | +## Registration model and the `remote!` macro |
| 6 | + |
| 7 | +Remote-spawnable actors are registered using the `remote!` macro. Given an actor type that implements [`RemoteSpawn`](./remote_spawn.md) and `Named`, `remote!(MyActor)` arranges for a `SpawnableActor` record to be submitted to a global registry using the [`inventory`](https://docs.rs/inventory/0.3.21/inventory/index.html) crate. |
| 8 | + |
| 9 | +In idiomatic use: |
| 10 | +```rust |
| 11 | +#[derive(Debug)] |
| 12 | +#[hyperactor::export(handlers = [()])] |
| 13 | +struct MyActor; |
| 14 | + |
| 15 | +#[async_trait::async_trait] |
| 16 | +impl Actor for MyActor {} |
| 17 | + |
| 18 | +#[async_trait::async_trait] |
| 19 | +impl RemoteSpawn for MyActor { |
| 20 | + type Params = bool; |
| 21 | + |
| 22 | + async fn new(params: bool) -> anyhow::Result<Self> { |
| 23 | + if params { |
| 24 | + Ok(MyActor) |
| 25 | + } else { |
| 26 | + Err(anyhow::anyhow!("some failure")) |
| 27 | + } |
| 28 | + } |
| 29 | +} |
| 30 | + |
| 31 | +remote!(MyActor); |
| 32 | +``` |
| 33 | + |
| 34 | +Conceptually, the `remote!` invocation expands to something like: |
| 35 | +```rust |
| 36 | +static MY_ACTOR_NAME: std::sync::LazyLock<&'static str> = |
| 37 | + std::sync::LazyLock::new(|| <MyActor as hyperactor::data::Named>::typename()); |
| 38 | + |
| 39 | +inventory::submit! { |
| 40 | + hyperactor::actor::remote::SpawnableActor { |
| 41 | + name: &MY_ACTOR_NAME, |
| 42 | + gspawn: <MyActor as hyperactor::actor::RemoteSpawn>::gspawn, |
| 43 | + get_type_id: <MyActor as hyperactor::actor::RemoteSpawn>::get_type_id, |
| 44 | + } |
| 45 | +} |
| 46 | +``` |
| 47 | +The real macro uses `paste!` to synthesize the `MY_ACTOR_NAME` identifier and the crate-local paths, but the effect is the same: |
| 48 | +- compute a **global type name** for `MyActor` via `Named::typename()`, |
| 49 | +- build a `SpawnableActor` record that points at `MyActor`s `RemoteSpawn` implementation, and |
| 50 | +- submit that record into the inventory of `SpawnableActor` entries. |
| 51 | + |
| 52 | +At runtime, the `Remote` registry (described below) discovers all such submissions via `inventory::iter::<SpawnableActor>` and makes them available for lookup and spawning by global type name. |
| 53 | + |
| 54 | +## `SpawnableActor`: registration records |
| 55 | + |
| 56 | +A `SpawnableActor` is the type-erased registration record produced by `remote!`. Each remotely spawnable actor type contributes exactly one of these records to the process. The registry discovers them at runtime and uses them to look up actors by global type name and to invoke their type-erased constructor. |
| 57 | +```rust |
| 58 | +#[derive(Debug)] |
| 59 | +pub struct SpawnableActor { |
| 60 | + /// A URI that globally identifies an actor. |
| 61 | + pub name: &'static LazyLock<&'static str>, |
| 62 | + |
| 63 | + pub gspawn: fn( |
| 64 | + &Proc, |
| 65 | + &str, |
| 66 | + Data, |
| 67 | + ) -> Pin<Box<dyn Future<Output = Result<ActorId, anyhow::Error>> + Send>>, |
| 68 | + |
| 69 | + pub get_type_id: fn() -> TypeId, |
| 70 | +} |
| 71 | +``` |
| 72 | +- `name` is the actor's global type name, obtained from `Named::typename()`. This is the string that appears on the wire in a remote-spawn request. |
| 73 | +- `gspawn` is the type-erased entry point for constructing the actor on a remote `Proc`. It is backed by the actor's `RemoteSpawn::gspawn` implementation and handles deserializing parameters and invoking `RemoteSpawn::new(...).await`. |
| 74 | +- `get_type_id` returns the actor's `TypeId`, allowing the registry to map a concrete Rust type back to it's registration entry. |
| 75 | + |
| 76 | +Users never construct a `SpawnableActor` manually; these records are generated automatically by the `remote!` macro. |
| 77 | + |
| 78 | +## The `Remote` registry |
| 79 | + |
| 80 | +The `Remote` type is the process-local registry of remote-spawnable actors. It is built from all `SpawnableActor` records submitted via `remote!` and exposed through two lookups: by global type name and by `TypeId`. |
| 81 | +```rust |
| 82 | +#[derive(Debug)] |
| 83 | +pub struct Remote { |
| 84 | + by_name: HashMap<&'static str, &'static SpawnableActor>, |
| 85 | + by_type_id: HashMap<TypeId, &'static SpawnableActor>, |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +- `by_type_id` is used by `Remote::name_of::<A>()`, which starts from a concrete type `A: Actor` and looks up its `SpawnableActor` in order to read the registered name. |
| 90 | +- `by_name` is used by `Remote::gspawn`, which starts from a global type name string received over the wire and looks up the corresponding `SpawnableActor` in order to call its `gspawn` function. |
| 91 | + |
| 92 | +This is why the registry maintains two maps: one keyed by `TypeId` for caller-side APIs that start from a Rust type, and one keyed by string name for remote services that start from a serialized request. |
| 93 | + |
| 94 | +### Building the registry: `Remote::collect` |
| 95 | + |
| 96 | +```rust |
| 97 | +impl Remote { |
| 98 | + pub fn collect() -> Self { |
| 99 | + let mut by_name = HashMap::new(); |
| 100 | + let mut by_type_id = HashMap::new(); |
| 101 | + for entry in inventory::iter::<SpawnableActor> { |
| 102 | + if by_name.insert(**entry.name, entry).is_some() { |
| 103 | + panic!("actor name {} registered multiple times", **entry.name); |
| 104 | + } |
| 105 | + let type_id = (entry.get_type_id)(); |
| 106 | + if by_type_id.insert(type_id, entry).is_some() { |
| 107 | + panic!( |
| 108 | + "type id {:?} ({}) registered multiple times", |
| 109 | + type_id, **entry.name |
| 110 | + ); |
| 111 | + } |
| 112 | + } |
| 113 | + Self { by_name, by_type_id } |
| 114 | + } |
| 115 | +} |
| 116 | +``` |
| 117 | +`Remote::collect` walks `inventory::iter::<SpawnableActor>` and builds two maps: |
| 118 | +- `by_name` for lookup up actors by their global type name (the string that appears on the wire), and |
| 119 | +- `by_type_id` for looking up the registration associated with a concrete Rust type. |
| 120 | + |
| 121 | +It enforces that no two actors register the same global name or `TypeId` in a single binary. |
| 122 | + |
| 123 | +The result is a process-local view of all remote-spawnable actors; callers are free to construct this registry once and reuse it or to rebuild it on demand, depending on their needs. |
| 124 | + |
| 125 | +### Looking up names: `Remote::name_of` |
| 126 | + |
| 127 | +```rust |
| 128 | +impl Remote { |
| 129 | + pub fn name_of<A: Actor>(&self) -> Option<&'static str> { |
| 130 | + self.by_type_id |
| 131 | + .get(&TypeId::of::<A>()) |
| 132 | + .map(|entry| **entry.name) |
| 133 | + } |
| 134 | +} |
| 135 | +``` |
| 136 | +`name_of` resolves a concrete `A: RemoteSpawn` to its registered global type name string. |
| 137 | + |
| 138 | +Given a concrete `A: Actor`, `name_of` returns the string name that was registered via `remote!`. This is used by caller-side APIs that *start from a Rust type* and need to put a string type name on the wire for a remote spawn request. |
| 139 | + |
| 140 | +For example, `spawn_with_name_inner` constructs an `ActorSpec` by first resolving the type `A` to its global name: |
| 141 | +```rust |
| 142 | +impl ProcMeshRef { |
| 143 | + async fn spawn_with_name_inner<A: RemoteSpawn>( |
| 144 | + &self, |
| 145 | + cx: &impl context::Actor, |
| 146 | + name: Name, |
| 147 | + params: &A::Params, |
| 148 | + ) -> v1::Result<ActorMesh<A>> |
| 149 | + { |
| 150 | + let remote = Remote::collect(); |
| 151 | + |
| 152 | + // Caller starts from the Rust type `A` → resolve to a global type name. |
| 153 | + let actor_type = remote |
| 154 | + .name_of::<A>() |
| 155 | + .ok_or(Error::ActorTypeNotRegistered(type_name::<A>().to_string()))? |
| 156 | + .to_string(); |
| 157 | + |
| 158 | + let serialized_params = bincode::serialize(params)?; |
| 159 | + let agent_mesh = self.agent_mesh(); |
| 160 | + |
| 161 | + agent_mesh.cast( |
| 162 | + cx, |
| 163 | + resource::CreateOrUpdate::<mesh_agent::ActorSpec> { |
| 164 | + name: name.clone(), |
| 165 | + rank: Default::default(), |
| 166 | + spec: mesh_agent::ActorSpec { |
| 167 | + actor_type: actor_type.clone(), // ← string name sent over the wire |
| 168 | + params_data: serialized_params.clone(), |
| 169 | + }, |
| 170 | + }, |
| 171 | + )?; |
| 172 | + |
| 173 | + ... |
| 174 | + } |
| 175 | +``` |
| 176 | +Here the caller begins with the Rust type `A` and uses `name_of::<A>()` to obtain the global name that will be sent to the remote `Proc`. On the receiving side, the registry takes the global type name string, resolves it to a `SpawnableActor`, and then invokes that entry's `gspawn` function to construct the actor. |
| 177 | + |
| 178 | +### Spawning by name: `Remote::gspawn` |
| 179 | +```rust |
| 180 | +impl Remote { |
| 181 | + pub async fn gspawn( |
| 182 | + &self, |
| 183 | + proc: &Proc, |
| 184 | + actor_type: &str, |
| 185 | + actor_name: &str, |
| 186 | + params: Data, |
| 187 | + ) -> Result<ActorId, anyhow::Error> { |
| 188 | + let entry = self |
| 189 | + .by_name |
| 190 | + .get(actor_type) |
| 191 | + .ok_or_else(|| anyhow::anyhow!("actor type {} not registered", actor_type))?; |
| 192 | + (entry.gspawn)(proc, actor_name, params).await |
| 193 | + } |
| 194 | +} |
| 195 | +``` |
| 196 | +`gspawn` is the **name -> spawn** path. It starts from a a global type name string (`actor_type`), looks up the corresponding `SpawnableActor` in `by_name`, and invokes its `gspawn` function. That function is the type-erased adapter provided by the actor's `RemoteSpawn` implementation: it deserializes `params` into `RemoteSpawn::Params`, calls `RemoteSpawn::new`, wires the actor into the given `Proc`, and returns the resulting `ActorId`. |
| 197 | + |
| 198 | +In a typical setup, higher-level code in a separate crate starts from a generic `A: RemoteSpawn`, uses `Remote::name_of::<A>()` to obtain the global type name, serializes `A::Params`, and sends a request containing: |
| 199 | +- `actor_type`: that global type name, and |
| 200 | +- `params_data`: serialized `A::Params`. |
| 201 | + |
| 202 | +On the receiving side, a control-plane or management actor calls: |
| 203 | +```rust |
| 204 | +self.remote.gspawn(&self.proc, &actor_type, &actor_name, params_data).await |
| 205 | +``` |
| 206 | +to look up the corresponding `SpawnableActor` by `actor_type` and invoke its `gspawn` entry point. That call deserializes `params_data`, constructs the actor, wires it into `self.proc` and returns the new `ActorId`. |
| 207 | + |
| 208 | +## Putting it together |
| 209 | + |
| 210 | +Remote spawning in hyperactor involves two complementary pieces: |
| 211 | +1. **Type-level registration** Each `A: RemoteSpawn` contributes a `SpawnableActor` record when the user writes `remote!(A)`. These records are collected at runtime by `Remote::collect()`. |
| 212 | +2. **Data-level spawn requests** Higher-level code starts from a concrete actor type (e.g. `A: RemoteSpawn`), uses `Remote::name_of::<A>()` to obtain it's global type name, serializes `A::Params`, and sends a request containing those two pieces of data. |
| 213 | + |
| 214 | +On the receiving side, a management component reconstructs the actor by calling: |
| 215 | +```rust |
| 216 | +remote.gspawn(&proc, &actor_type, &actor_name, params_data).await |
| 217 | +``` |
| 218 | + |
| 219 | +`Remote::gspawn` uses the global type name to locate the correct `SpawnableActor` and invokes its type-erased `gspawn` function, which: |
| 220 | +- deserializes `params_data`, |
| 221 | +- calls `A::new(params).await`, and |
| 222 | +- installs the actor into the provided `Proc`. |
| 223 | + |
| 224 | +The `Remote` registry is thus the bridge between: |
| 225 | +- **Rust types** implementing `RemoteSpawn` (which define how to construct the actor), and |
| 226 | +- **runtime identifiers** (global type names) used in serialized remote-spawn requests. |
| 227 | + |
| 228 | +This decoupling allows remote spawning to work across processes without requiring shared type information at compile time: all that crosses the wire is a global name and a parameter payload, and the receiving process uses its local registry to handle construction. |
0 commit comments