feat(cluster): RFC 010 c7b — discovery Strategy, static seeds, connector dial loop
Phase 2 gate: 3-node mesh under the subprocess harness, repeatable (10/10).
Strategy (ratified): push-based, spawned as its own actor by the connector —
it emits Discovery events into a channel whenever it learns something and
may run forever; the connector owns all retry/backoff state. StaticSeeds
announces its list once and exits. Discovery is #[non_exhaustive] and
additive-only (candidates announced, never withdrawn) so expiry can land
later without breaking strategies.
One-viable correction to the ratified Discovery shape, flagged: a candidate
is a (name, addr) PAIR, not a bare address. The dial path and the D7
tie-break are keyed by peer name (the dial intent must be registered before
connecting so a crossing inbound Hello sees it), so an anonymous dial would
reintroduce exactly the simultaneous-connect flap D7 exists to prevent.
Discovery mechanisms know names — that is what they discover.
Connector: plain select-loop actor (the c6 shape) folding cmd inbox,
discovery stream, membership stream, and the earliest retry deadline into
one wait. It tracks who is up by SUBSCRIBING TO MEMBERSHIP like any
consumer — first consumer of c7a's snapshot-then-stream surface, no
privileged channel into the manager. Backoff: 250ms doubling to a 5s cap
(the c6c class of one-viable constants), reset on node_up; node_down
schedules a prompt redial with a fresh sequence. A candidate bearing the
local name is parked (that seed is us); every other failure retries — in
particular NameTaken can be our own ghost at the peer, not yet reaped by
its liveness timer, so it must not park. Dials run inline in the loop, the
acceptor's deliberate serialization (each attempt bounded by the connect +
handshake deadlines).
cluster::start(Config {node_name, meta, listen_addr, strategy}) is now the
integrated node start: supervised manager + acceptor + connector. It
completes the node identity: build_hash = cluster::BUILD_HASH (first
consumer, closing the c6d loose end) and incarnation = self_incarnation()
— unix-epoch MILLIS truncated to u32, not seconds: a supervised
crash-and-restart inside one second is routine, and seconds would collide
the ghost with its successor. Cluster handle: local_addr()/local()/
shutdown(); drop stops acceptor+connector loops, manager subtree detaches
(same split as AcceptorHandle alone).
Roadmap-binding, asserted in review: no consumer touches the connection
table — Manager.conns and ConnEntry stay private; the only exposures are
Call::Peers (sorted names, pre-existing) and the membership surface.
tests/cluster_mesh.rs 2/0, 10/10 flake runs: (1) 3-node mesh forms; kill
one (SIGKILL via Drop, per the retractable-state trap: roles park forever)
=> node_down at both survivors; restart same name => new incarnation at
every observer, distinguishable from the ghost; (2) seed unreachable at
start (pre-reserved closed port; accepted micro steal-window, documented)
then arriving later => edge forms via the retry path. All cluster suites
regression-clean (envelope 15, handshake 11, transport 11, lifecycle 1,
liveness 3, connect 9, two_node 3, membership 4); clippy --lib green both
configs; fmt clean; default build compiles.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
//! RFC 010 c7b — peer discovery: the [`Strategy`] seam and the static-seeds
|
||||
//! implementation.
|
||||
//!
|
||||
//! A strategy is **push-based and runs as its own actor**: the
|
||||
//! [`connector`](crate::cluster::connector) spawns it with the sending end of
|
||||
//! a channel, and the strategy emits [`Discovery`] events whenever it learns
|
||||
//! something — once at startup for a static list, continuously for a future
|
||||
//! mDNS/DNS strategy — for as long as it cares to run. Returning ends the
|
||||
//! strategy actor; the candidates it pushed live on in the connector (the
|
||||
//! connector owns all retry/backoff state, so a strategy never re-announces).
|
||||
//!
|
||||
//! A candidate is a **`(node_name, addr)` pair**, not a bare address: the
|
||||
//! dial path and the D7 tie-break are keyed by peer *name* (the dial intent
|
||||
//! must be registered before connecting so a crossing inbound `Hello` sees
|
||||
//! it), so an anonymous dial would reintroduce exactly the
|
||||
//! simultaneous-connect flap D7 exists to prevent. Discovery mechanisms know
|
||||
//! names — that is what they discover.
|
||||
|
||||
use crate::channel::Sender;
|
||||
|
||||
/// A discovery event, as pushed by a [`Strategy`].
|
||||
///
|
||||
/// Additive-only for now (candidates are announced, never withdrawn);
|
||||
/// `#[non_exhaustive]` so expiry can land later without breaking strategies.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Discovery {
|
||||
/// A peer worth dialing: its claimed node name and a dialable address.
|
||||
Candidate { name: String, addr: String },
|
||||
}
|
||||
|
||||
/// A source of peers to dial. Implementations are spawned as actors by the
|
||||
/// connector — see the module docs for the contract.
|
||||
pub trait Strategy: Send + 'static {
|
||||
/// Run the strategy: push [`Discovery`] events into `out` as they are
|
||||
/// learned; return when done discovering (or when `out` reports closed —
|
||||
/// the connector is gone). Runs inside an actor, so blocking
|
||||
/// cooperatively is fine.
|
||||
fn run(self: Box<Self>, out: Sender<Discovery>);
|
||||
}
|
||||
|
||||
/// The static-seeds strategy: a fixed `(name, addr)` list, announced once.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StaticSeeds {
|
||||
seeds: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl StaticSeeds {
|
||||
pub fn new(seeds: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> Self {
|
||||
StaticSeeds {
|
||||
seeds: seeds
|
||||
.into_iter()
|
||||
.map(|(n, a)| (n.into(), a.into()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Strategy for StaticSeeds {
|
||||
fn run(self: Box<Self>, out: Sender<Discovery>) {
|
||||
for (name, addr) in self.seeds {
|
||||
if out.send(Discovery::Candidate { name, addr }).is_err() {
|
||||
return; // connector gone; nobody to discover for
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user