//! `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. Resolve { actor: ActorAddress, reply: ActorAddress, }, /// 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, } pub struct DirectoryActor { self_id: NodeId, /// The location map: one signed claim per actor. The only writer is [`Self::merge_one`]. map: HashMap, /// Alive peers (excludes self), folded from the membership stream — the /// dissemination fan-out set and the `cluster_size` budget basis. alive: BTreeSet, /// 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, /// 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, peer_directory: Arc, /// 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, } impl DirectoryActor { pub fn new( self_id: NodeId, peer_directory: Arc, route_view: RouteView, route_binder: Arc, ) -> 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) { 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 = 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 = 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 = 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 { let actors: Vec = 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 = 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 }); } } } }