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:
@@ -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)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user