Replace the eventfd/ring job bootstrap with one inherited arena descriptor, actor-owned sessions, sealed blob leases, awaitable inbox wakeups, and zero-copy Python mappings. Route VastAI mock provisioning through image-backed local Docker workers and preserve pinned child and controller routes across directory updates. Add TOML job-file submission to the Fleet UI with generic started, running, and completed feedback, reusable remote job controller routing, cancellation and kill invariants, Tinygrad fixture and image support, and comprehensive Rust, Python, CUDA, and lifecycle-ordering coverage.
313 lines
13 KiB
Rust
313 lines
13 KiB
Rust
//! `DirectoryActor` — the actor→host location directory (`DIRECTORY.md`) as a
|
|
//! standalone gossip actor.
|
|
//!
|
|
//! It is the fourth standalone gossip actor, a peer of [`SwimActor`], [`RegistryActor`],
|
|
//! and [`MetadataActor`] — not a layer above them. It owns one signed claim per
|
|
//! actor (a [`DirectoryEntry`], the spec's `Claim`), converges that location map
|
|
//! across the cluster by lazy round-robin gossip on `Tick`, and publishes a
|
|
//! [`RouteView`] read-mirror the egress consults to route an application message to
|
|
//! an actor it knows only by address (`DIRECTORY.md` §5).
|
|
//!
|
|
//! It differs from the registry/metadata template in only three ways:
|
|
//! 1. it stores signed claims directly (no wrapped CRDT engine), one per actor;
|
|
//! 2. it **verifies the signature on every merge**, so a forged claim is dropped;
|
|
//! 3. it publishes a [`RouteView`] (`ActorAddress → NodeId`) instead of a
|
|
//! [`RelayMirror`](crate::transport_bridge::RelayMirror) (`NodeId → relay URL`).
|
|
//!
|
|
//! Routing is **blind, best-effort**: there are no acks, no retries, no awaited
|
|
//! replies. The single wire-crossing edge is [`DirectoryIn::Gossip`]; every other
|
|
//! variant is local control. Like SWIM, the actor reads no ambient clock — time
|
|
//! enters only as `Tick`.
|
|
//!
|
|
//! [`SwimActor`]: crate::swim::actor::SwimActor
|
|
//! [`RegistryActor`]: crate::registry_actor::RegistryActor
|
|
//! [`MetadataActor`]: crate::node_metadata_actor::MetadataActor
|
|
|
|
use std::collections::{BTreeSet, HashMap};
|
|
use std::sync::Arc;
|
|
|
|
use swactor::actor::{ActorAddress, ActorInterface};
|
|
use swactor::runtime::Ctx;
|
|
|
|
use crate::crypto::verify_directory_entry;
|
|
use crate::messages::DirectoryGossip;
|
|
use crate::swim::actor::{MembershipChanged, PeerDirectory};
|
|
use crate::transport_bridge::{RouteBinder, RouteView};
|
|
use crate::types::{DirectoryEntry, MemberState, NodeId};
|
|
|
|
/// Round-robin gossip fan-out per `Tick` (the base dissemination width).
|
|
const FANOUT: u32 = 3;
|
|
/// Budget ceiling, in multiples of [`FANOUT`]: a claim is sent at most
|
|
/// `FANOUT * MAX_ROUNDS` times before it stops being re-pushed.
|
|
const MAX_ROUNDS: u32 = 8;
|
|
/// Claims shipped in a single gossip batch per `Tick`.
|
|
const BATCH: usize = 16;
|
|
/// Dead hosts are hidden from the published [`RouteView`], but their signed
|
|
/// claims stay cached so a false-dead peer can become routable again as soon as
|
|
/// SWIM reports it Alive. Claim deletion requires a future explicit tombstone or
|
|
/// owner-side lifecycle signal; raw `Tick` cadence is not a safe GC clock.
|
|
|
|
/// Everything the `DirectoryActor` receives, as one enum. Only [`Gossip`](DirectoryIn::Gossip)
|
|
/// crosses the wire (it carries the registered `DirectoryGossip` tag); the rest
|
|
/// are local control — see [`crate::messages::actor_codec_registry`].
|
|
#[derive(Clone)]
|
|
pub enum DirectoryIn {
|
|
/// Local: a host-signed claim for an actor spawned on this node. The host is
|
|
/// the claim's signer; merging it begins disseminating it.
|
|
Register(DirectoryEntry),
|
|
/// Gossip from a peer: a batch of signed claims to merge.
|
|
Gossip(DirectoryGossip),
|
|
/// Membership delta, adapted from the SwimActor's `MembershipChanged` stream.
|
|
Membership(MembershipChanged),
|
|
/// Local diagnostic read (`DIRECTORY.md` §5): resolve `actor`'s host from the
|
|
/// converged map; the answer is sent to `reply`. Off the hot path — the
|
|
/// load-bearing read is the [`RouteView`], read directly by the egress.
|
|
Resolve {
|
|
actor: ActorAddress,
|
|
reply: ActorAddress,
|
|
},
|
|
/// Clock: disseminate one batch to one peer.
|
|
Tick,
|
|
}
|
|
|
|
/// Reply to [`DirectoryIn::Resolve`] (diagnostics/tooling only).
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Located {
|
|
pub actor: ActorAddress,
|
|
pub host: Option<NodeId>,
|
|
}
|
|
|
|
pub struct DirectoryActor {
|
|
self_id: NodeId,
|
|
/// The location map: one signed claim per actor. The only writer is [`Self::merge_one`].
|
|
map: HashMap<ActorAddress, DirectoryEntry>,
|
|
/// Alive peers (excludes self), folded from the membership stream — the
|
|
/// dissemination fan-out set and the `cluster_size` budget basis.
|
|
alive: BTreeSet<NodeId>,
|
|
/// Actors still owing dissemination → remaining sends. A freshly authored or
|
|
/// freshly-superseded claim is armed here; budget decays to zero in steady
|
|
/// state, so a settled cluster goes quiet.
|
|
hot: HashMap<ActorAddress, u32>,
|
|
/// Round-robins the gossip target across `alive`, one per `Tick`.
|
|
cursor: usize,
|
|
peer_directory: Arc<dyn PeerDirectory>,
|
|
/// The §5 read mirror. Single writer = this actor; republished on every change.
|
|
route_view: RouteView,
|
|
/// Registers a route so the runtime can deliver an app message addressed to a
|
|
/// remotely-hosted actor (the §5 egress seam). Called from [`Self::republish`].
|
|
route_binder: Arc<dyn RouteBinder>,
|
|
/// Routes owned outside SWIM membership (for example an exec child actor
|
|
/// runtime attached to this host). Directory republishing always preserves
|
|
/// these entries.
|
|
pinned_routes: RouteView,
|
|
}
|
|
|
|
impl DirectoryActor {
|
|
pub fn new(
|
|
self_id: NodeId,
|
|
peer_directory: Arc<dyn PeerDirectory>,
|
|
route_view: RouteView,
|
|
route_binder: Arc<dyn RouteBinder>,
|
|
) -> Self {
|
|
Self::with_pinned_routes(
|
|
self_id,
|
|
peer_directory,
|
|
route_view,
|
|
Arc::new(std::sync::RwLock::new(HashMap::new())),
|
|
route_binder,
|
|
)
|
|
}
|
|
|
|
pub fn with_pinned_routes(
|
|
self_id: NodeId,
|
|
peer_directory: Arc<dyn PeerDirectory>,
|
|
route_view: RouteView,
|
|
pinned_routes: RouteView,
|
|
route_binder: Arc<dyn RouteBinder>,
|
|
) -> Self {
|
|
Self {
|
|
self_id,
|
|
map: HashMap::new(),
|
|
alive: BTreeSet::new(),
|
|
hot: HashMap::new(),
|
|
cursor: 0,
|
|
peer_directory,
|
|
route_view,
|
|
route_binder,
|
|
pinned_routes,
|
|
}
|
|
}
|
|
|
|
/// Merge one claim under the supersession rule — the only writer of `map`.
|
|
///
|
|
/// A claim wins iff it is strictly newer by `(generation, node_id)`: a higher
|
|
/// generation supersedes; at equal generation the larger `node_id` is the
|
|
/// deterministic tie-break (so every node converges on the same winner). A
|
|
/// stale, equal, or forged claim is ignored. Merging the same claim twice is a
|
|
/// no-op — it does not re-arm dissemination, which is what lets the cluster go
|
|
/// quiet. Idempotent and commutative.
|
|
fn merge_one(&mut self, claim: DirectoryEntry) {
|
|
if !verify_directory_entry(&claim) {
|
|
return; // not signed by the host it names — drop it
|
|
}
|
|
let supersedes = match self.map.get(&claim.actor_addr) {
|
|
None => true,
|
|
Some(cur) => {
|
|
claim.generation > cur.generation
|
|
|| (claim.generation == cur.generation && claim.node_id > cur.node_id)
|
|
}
|
|
};
|
|
if supersedes {
|
|
let budget = self.budget();
|
|
self.hot.insert(claim.actor_addr, budget); // arm for dissemination
|
|
self.map.insert(claim.actor_addr, claim);
|
|
}
|
|
}
|
|
|
|
fn register(&mut self, claim: DirectoryEntry) {
|
|
self.merge_one(claim);
|
|
self.republish();
|
|
}
|
|
|
|
fn merge_batch(&mut self, claims: Vec<DirectoryEntry>) {
|
|
for c in claims {
|
|
self.merge_one(c);
|
|
}
|
|
self.republish();
|
|
}
|
|
|
|
fn on_membership(&mut self, change: MembershipChanged) {
|
|
match change.state {
|
|
MemberState::Alive if change.node_id != self.self_id => {
|
|
if self.alive.insert(change.node_id) {
|
|
// A (re)joining peer: re-arm every held claim so the returning
|
|
// peer is caught up — without a full-cluster reflood (only the
|
|
// actors we hold, and only via the lazy push).
|
|
let budget = self.budget();
|
|
let actors: Vec<ActorAddress> = self.map.keys().copied().collect();
|
|
for actor in actors {
|
|
self.hot.insert(actor, budget);
|
|
}
|
|
}
|
|
}
|
|
MemberState::Dead => {
|
|
// Drop from the alive set; republish hides its actors from routing.
|
|
// Keep the signed claim cached so a false-dead host can recover
|
|
// without depending on wall-clock or pump-tick based GC.
|
|
self.alive.remove(&change.node_id);
|
|
}
|
|
_ => {} // Suspect, or self: ignore (suspicion is SWIM's transient state)
|
|
}
|
|
self.republish();
|
|
}
|
|
|
|
fn tick(&mut self, ctx: &Ctx) {
|
|
self.disseminate(ctx);
|
|
}
|
|
|
|
/// Send one batch of armed claims to one alive peer (round-robin). Skips the
|
|
/// send when there is no peer or nothing armed, so budget is never burned into
|
|
/// the void — mirrors the registry/metadata disseminators.
|
|
fn disseminate(&mut self, ctx: &Ctx) {
|
|
if self.alive.is_empty() || self.hot.is_empty() {
|
|
return;
|
|
}
|
|
let peers: Vec<NodeId> = self.alive.iter().copied().collect();
|
|
let peer = peers[self.cursor % peers.len()];
|
|
self.cursor = self.cursor.wrapping_add(1);
|
|
let claims = self.take_hot(BATCH);
|
|
if claims.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(addr) = self.peer_directory.resolve(&peer) {
|
|
let _ = ctx.send(addr, DirectoryIn::Gossip(DirectoryGossip { claims }));
|
|
}
|
|
}
|
|
|
|
/// Republish the §5 route view: every actor whose host is reachable right now
|
|
/// (self, or an alive peer). A dead host's actors are omitted, so the egress
|
|
/// never routes to a host SWIM has buried; the claim remains cached for recovery.
|
|
///
|
|
/// Builds the new view locally, swaps it in under the lock, then registers a
|
|
/// route for each remotely-hosted actor *outside* the lock — so the egress
|
|
/// (which takes the view's read lock on every send) never contends with the
|
|
/// route registration.
|
|
fn republish(&self) {
|
|
let pinned = self
|
|
.pinned_routes
|
|
.read()
|
|
.expect("pinned route view poisoned");
|
|
let mut view = pinned.clone();
|
|
let mut remote = pinned
|
|
.iter()
|
|
.filter_map(|(actor, node)| (*node != self.self_id).then_some(*actor))
|
|
.collect::<Vec<_>>();
|
|
drop(pinned);
|
|
for (actor, claim) in &self.map {
|
|
if view.contains_key(actor) {
|
|
continue;
|
|
}
|
|
if claim.node_id == self.self_id {
|
|
view.insert(*actor, claim.node_id);
|
|
} else if self.alive.contains(&claim.node_id) {
|
|
view.insert(*actor, claim.node_id);
|
|
remote.push(*actor);
|
|
}
|
|
}
|
|
*self.route_view.write().expect("route view poisoned") = view;
|
|
for actor in remote {
|
|
self.route_binder.ensure_routable(actor);
|
|
}
|
|
}
|
|
|
|
/// Take up to `limit` armed claims for this tick's batch, spending one unit of
|
|
/// each one's budget. A claim whose budget reaches zero stops being re-pushed.
|
|
fn take_hot(&mut self, limit: usize) -> Vec<DirectoryEntry> {
|
|
let actors: Vec<ActorAddress> = self.hot.keys().copied().take(limit).collect();
|
|
let mut claims = Vec::with_capacity(actors.len());
|
|
for actor in actors {
|
|
if let Some(c) = self.map.get(&actor) {
|
|
claims.push(c.clone());
|
|
}
|
|
if let Some(left) = self.hot.get_mut(&actor) {
|
|
*left = left.saturating_sub(1);
|
|
if *left == 0 {
|
|
self.hot.remove(&actor);
|
|
}
|
|
}
|
|
}
|
|
claims
|
|
}
|
|
|
|
/// Per-claim dissemination budget: `FANOUT · ⌈log2(cluster+1)⌉`, clamped to
|
|
/// `[FANOUT, FANOUT·MAX_ROUNDS]`. Grows gently with cluster size so a claim
|
|
/// reaches enough seeds for epidemic spread to finish the job, but is bounded.
|
|
fn budget(&self) -> u32 {
|
|
let n = (self.alive.len() + 1) as u32; // include self
|
|
let log2 = u32::BITS - n.leading_zeros(); // bit-length of n ≈ ⌈log2(n+1)⌉
|
|
(FANOUT * log2).clamp(FANOUT, FANOUT * MAX_ROUNDS)
|
|
}
|
|
}
|
|
|
|
impl ActorInterface for DirectoryActor {
|
|
type Incoming = DirectoryIn;
|
|
type Response = ();
|
|
|
|
fn handle(&mut self, ctx: &Ctx, msg: DirectoryIn) {
|
|
match msg {
|
|
DirectoryIn::Register(claim) => self.register(claim),
|
|
DirectoryIn::Gossip(batch) => self.merge_batch(batch.claims),
|
|
DirectoryIn::Membership(change) => self.on_membership(change),
|
|
DirectoryIn::Tick => self.tick(ctx),
|
|
DirectoryIn::Resolve { actor, reply } => {
|
|
let host = self
|
|
.route_view
|
|
.read()
|
|
.expect("route view poisoned")
|
|
.get(&actor)
|
|
.copied();
|
|
let _ = ctx.send(reply, Located { actor, host });
|
|
}
|
|
}
|
|
}
|
|
}
|