2026-06-09 09:29:07 +00:00
|
|
|
//! `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;
|
|
|
|
|
/// Grace window (in `Tick`s) before a gone host's claims are reclaimed from the
|
|
|
|
|
/// map. ~5s at the live node's 100 ms cadence. Routing already hides a dead host
|
|
|
|
|
/// (see [`DirectoryActor::republish`]); this is memory hygiene only.
|
|
|
|
|
const GC_GRACE_TICKS: u32 = 50;
|
|
|
|
|
|
|
|
|
|
/// 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.
|
2026-06-23 15:42:28 +00:00
|
|
|
Resolve {
|
|
|
|
|
actor: ActorAddress,
|
|
|
|
|
reply: ActorAddress,
|
|
|
|
|
},
|
2026-06-09 09:29:07 +00:00
|
|
|
/// Clock: disseminate one batch to one peer, then run GC.
|
|
|
|
|
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,
|
|
|
|
|
/// Per-gone-host GC grace countdown. Internal bookkeeping (not in `DIRECTORY.md`
|
|
|
|
|
/// §4.2's state); a host that returns is cleared in [`Self::on_membership`].
|
|
|
|
|
absent: HashMap<NodeId, u32>,
|
|
|
|
|
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>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DirectoryActor {
|
|
|
|
|
pub fn new(
|
|
|
|
|
self_id: NodeId,
|
|
|
|
|
peer_directory: Arc<dyn PeerDirectory>,
|
|
|
|
|
route_view: RouteView,
|
|
|
|
|
route_binder: Arc<dyn RouteBinder>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
self_id,
|
|
|
|
|
map: HashMap::new(),
|
|
|
|
|
alive: BTreeSet::new(),
|
|
|
|
|
hot: HashMap::new(),
|
|
|
|
|
cursor: 0,
|
|
|
|
|
absent: HashMap::new(),
|
|
|
|
|
peer_directory,
|
|
|
|
|
route_view,
|
|
|
|
|
route_binder,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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: clear any pending GC and 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).
|
|
|
|
|
self.absent.remove(&change.node_id);
|
|
|
|
|
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,
|
|
|
|
|
// and gc reclaims the claims after the grace window.
|
|
|
|
|
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);
|
|
|
|
|
self.gc();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 — even before GC reclaims the claim.
|
|
|
|
|
///
|
|
|
|
|
/// 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 mut view = HashMap::with_capacity(self.map.len());
|
|
|
|
|
let mut remote: Vec<ActorAddress> = Vec::new();
|
|
|
|
|
for (actor, claim) in &self.map {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reclaim claims whose host has been gone past the grace window. Memory
|
|
|
|
|
/// hygiene only — [`Self::republish`] already hides a dead host from routing.
|
|
|
|
|
fn gc(&mut self) {
|
|
|
|
|
let gone: Vec<NodeId> = self
|
|
|
|
|
.map
|
|
|
|
|
.values()
|
|
|
|
|
.map(|c| c.node_id)
|
|
|
|
|
.filter(|h| *h != self.self_id && !self.alive.contains(h))
|
|
|
|
|
.collect();
|
|
|
|
|
for h in gone {
|
|
|
|
|
let n = self.absent.entry(h).or_insert(0);
|
|
|
|
|
*n += 1;
|
|
|
|
|
if *n > GC_GRACE_TICKS {
|
|
|
|
|
self.map.retain(|_, c| c.node_id != h);
|
|
|
|
|
self.absent.remove(&h);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// A host that came back was already cleared in on_membership(Alive).
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|