pg: Phase 3 — generation-checked liveness backstop on reads (RFC 012)

members/pick now filter through a lock-free, generation-checked slot-word
read (live(), identical to the registry's guard) so a member that is already
dead but whose Down has not yet been drained is never *returned*. Eviction
stays the monitor's job (reap_group); this is the belt-and-braces read guard.

- members_where / first_member_where take an is_live oracle and replace the
  unconditional first_member; members_of becomes test-only (raw enumeration).
- Unit test: the backstop hides a member the monitor has not yet reaped, and
  does not itself evict (storage still holds it until reap).

Full suite green, warning-free.
This commit is contained in:
smarm-agent
2026-06-15 19:53:10 +00:00
parent 6464c3e92b
commit 3262c9527b
+61 -16
View File
@@ -17,8 +17,9 @@
//! group it touches first — draining each membership's monitor with a //! group it touches first — draining each membership's monitor with a
//! non-blocking `try_recv` — and on the first sign of death sweeps that pid out //! non-blocking `try_recv` — and on the first sign of death sweeps that pid out
//! of *every* group via the predicate primitive. So the set is self-pruning on //! of *every* group via the predicate primitive. So the set is self-pruning on
//! contact rather than filtered on read; Phase 3 adds a generation-checked //! contact rather than filtered on read; the read path additionally applies a
//! liveness read as a belt-and-braces backstop on the read path. //! generation-checked liveness backstop so a member already dead but not yet
//! reaped is never *returned*, even though eviction remains the monitor's job.
//! //!
//! [reap]: ProcessGroups::reap_group //! [reap]: ProcessGroups::reap_group
//! //!
@@ -231,9 +232,9 @@ impl ProcessGroups {
self.remove_where(|m| dead.contains(&m.pid)) self.remove_where(|m| dead.contains(&m.pid))
} }
/// Raw enumeration of a group's members — no liveness filtering (Phase 3 /// Raw enumeration of a group's members — no liveness filtering. Used by
/// layers a generation-checked backstop on the public `members`). Absent /// tests to assert storage state independently of the read-path backstop.
/// group → empty. #[cfg(test)]
fn members_of(&self, group: &str) -> Vec<Member> { fn members_of(&self, group: &str) -> Vec<Member> {
self.groups self.groups
.get(group) .get(group)
@@ -241,10 +242,22 @@ impl ProcessGroups {
.unwrap_or_default() .unwrap_or_default()
} }
/// The first member of `group`, if any — backing the stateless first-live /// Live members of `group`, in insertion order. The `is_live` oracle is the
/// `pick` without cloning the whole group. /// read-path backstop (Phase 3): a member whose slot is already dead is
fn first_member(&self, group: &str) -> Option<Member> { /// dropped from the *result* even if its `Down` has not been drained yet.
self.groups.get(group).and_then(|v| v.first().map(|e| e.member)) /// Backstop only — the entry stays in storage; eviction is the monitor's
/// job (`reap_group`).
fn members_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Vec<Pid> {
self.groups
.get(group)
.map(|v| v.iter().map(|e| e.member.pid).filter(|&p| is_live(p)).collect())
.unwrap_or_default()
}
/// The first live member of `group` in insertion order — stateless
/// first-live `pick`, with the same read-path backstop as `members_where`.
fn first_member_where(&self, group: &str, mut is_live: impl FnMut(Pid) -> bool) -> Option<Pid> {
self.groups.get(group)?.iter().map(|e| e.member.pid).find(|&p| is_live(p))
} }
} }
@@ -253,6 +266,14 @@ fn member_for(inner: &crate::runtime::RuntimeInner, pid: Pid) -> Member {
Member { node: inner.node_id, incarnation: inner.incarnation, pid } Member { node: inner.node_id, incarnation: inner.incarnation, pid }
} }
/// Is `pid` a live actor right now? Generation-checked atomic slot-word read,
/// no lock — identical to the registry's guard. The read-path backstop: a
/// generation is never reused, so a dead member is detectable independently of
/// whether its monitor `Down` has been drained yet.
fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
}
/// Add `pid` to `group`. The same pid may join many groups; within one group a /// Add `pid` to `group`. The same pid may join many groups; within one group a
/// pid is a member at most once (idempotent). Returns `true` if this call newly /// pid is a member at most once (idempotent). Returns `true` if this call newly
/// added the membership, `false` if it was already a member. /// added the membership, `false` if it was already a member.
@@ -316,15 +337,16 @@ pub fn leave(group: &str, pid: Pid) -> bool {
/// Fan-out read: every live member of `group`. /// Fan-out read: every live member of `group`.
/// ///
/// The touched group is reaped first, so dead members are evicted before the /// The touched group is reaped first, so dead members are evicted before the
/// read rather than filtered out of it. Phase 3 adds a generation-checked /// read. The generation-checked liveness read is a belt-and-braces backstop:
/// liveness read as a belt-and-braces backstop. /// even in the finalize window where a member is already dead but its `Down`
/// has not yet landed, it is dropped from the result.
/// ///
/// Panics if called outside `Runtime::run()`. /// Panics if called outside `Runtime::run()`.
pub fn members(group: &str) -> Vec<Pid> { pub fn members(group: &str) -> Vec<Pid> {
let (pids, reaped) = with_runtime(|inner| { let (pids, reaped) = with_runtime(|inner| {
let mut pg = inner.process_groups.lock(); let mut pg = inner.process_groups.lock();
let reaped = pg.reap_group(group); let reaped = pg.reap_group(group);
let pids: Vec<Pid> = pg.members_of(group).into_iter().map(|m| m.pid).collect(); let pids = pg.members_where(group, |pid| live(inner, pid));
(pids, reaped) (pids, reaped)
}); });
drop(reaped); drop(reaped);
@@ -332,16 +354,17 @@ pub fn members(group: &str) -> Vec<Pid> {
} }
/// Pool/discovery read: one live member of `group`, or `None` if empty. /// Pool/discovery read: one live member of `group`, or `None` if empty.
/// Stateless first-live selection (the touched group is reaped first, so the /// Stateless first-live selection: the touched group is reaped first, and the
/// first surviving member is live; smarter routing is an explicitly later, /// generation-checked liveness backstop skips any member already dead but not
/// clustered concern per RFC 010). /// yet reaped. Smarter routing is an explicitly later, clustered concern per
/// RFC 010.
/// ///
/// Panics if called outside `Runtime::run()`. /// Panics if called outside `Runtime::run()`.
pub fn pick(group: &str) -> Option<Pid> { pub fn pick(group: &str) -> Option<Pid> {
let (picked, reaped) = with_runtime(|inner| { let (picked, reaped) = with_runtime(|inner| {
let mut pg = inner.process_groups.lock(); let mut pg = inner.process_groups.lock();
let reaped = pg.reap_group(group); let reaped = pg.reap_group(group);
let picked = pg.first_member(group).map(|m| m.pid); let picked = pg.first_member_where(group, |pid| live(inner, pid));
(picked, reaped) (picked, reaped)
}); });
drop(reaped); drop(reaped);
@@ -495,4 +518,26 @@ mod tests {
assert_eq!(evicted.len(), 1); assert_eq!(evicted.len(), 1);
assert!(pg.members_of("a").is_empty()); assert!(pg.members_of("a").is_empty());
} }
#[test]
fn read_backstop_hides_a_member_the_monitor_has_not_yet_reaped() {
let mut pg = ProcessGroups::new();
// Both senders held: reap_group would see Ok(None) and evict neither.
let (a, _ta) = synth(1, 0);
let (b, _tb) = synth(2, 0);
pg.join("g", a);
pg.join("g", b);
// The slot-word oracle already reports pid 1 dead (finalize window),
// ahead of any Down delivery.
let dead = Pid::new(1, 0);
let oracle = |pid: Pid| pid != dead;
assert_eq!(pg.members_where("g", oracle), vec![Pid::new(2, 0)], "dead pid filtered from read");
assert_eq!(pg.first_member_where("g", oracle), Some(Pid::new(2, 0)), "pick skips the dead first member");
// Backstop does not evict — that stays the monitor's job; raw storage
// still holds both until reap runs.
assert_eq!(pg.members_of("g"), vec![member(1, 0), member(2, 0)]);
}
} }