use std::collections::HashSet;
use crate::computation_graph::reactor_lock_key::reactor_lock_key;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ReactorId {
pub tenant: Option<String>,
pub name: String,
}
impl ReactorId {
pub fn new(tenant: Option<impl Into<String>>, name: impl Into<String>) -> Self {
Self {
tenant: tenant.map(Into::into),
name: name.into(),
}
}
pub fn lock_key(&self) -> i64 {
reactor_lock_key(self.tenant.as_deref(), &self.name)
}
}
impl From<&crate::TenantKey> for ReactorId {
fn from(k: &crate::TenantKey) -> Self {
Self {
tenant: k.tenant_id.clone(),
name: k.name.clone(),
}
}
}
impl From<&ReactorId> for crate::TenantKey {
fn from(id: &ReactorId) -> Self {
Self {
tenant_id: id.tenant.clone(),
name: id.name.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OwnershipCheck {
AllHeld,
Lost(Vec<ReactorId>),
Indeterminate(String),
}
#[derive(Debug, Default)]
pub struct OwnershipState {
held: HashSet<ReactorId>,
}
impl OwnershipState {
pub fn new() -> Self {
Self::default()
}
pub fn record_claimed(&mut self, id: ReactorId) {
self.held.insert(id);
}
pub fn record_released(&mut self, id: &ReactorId) {
self.held.remove(id);
}
pub fn believes_owned(&self, id: &ReactorId) -> bool {
self.held.contains(id)
}
pub fn owned(&self) -> impl Iterator<Item = &ReactorId> {
self.held.iter()
}
pub fn len(&self) -> usize {
self.held.len()
}
pub fn is_empty(&self) -> bool {
self.held.is_empty()
}
pub fn diff_against_held_keys(&self, held_keys: &HashSet<i64>) -> Vec<ReactorId> {
let mut lost: Vec<ReactorId> = self
.held
.iter()
.filter(|id| !held_keys.contains(&id.lock_key()))
.cloned()
.collect();
lost.sort_by(|a, b| (&a.tenant, &a.name).cmp(&(&b.tenant, &b.name)));
lost
}
}
#[async_trait::async_trait]
pub trait ReactorOwnership: Send + Sync {
async fn claim(&self, id: &ReactorId) -> Result<bool, String>;
async fn release(&self, id: &ReactorId) -> Result<(), String>;
async fn verify(&self) -> OwnershipCheck;
async fn believed_owned(&self) -> Vec<ReactorId>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WatchdogAction {
Continue,
StopReactors(Vec<ReactorId>),
StopAllPresumedLost(Vec<ReactorId>),
}
#[derive(Debug)]
pub struct OwnershipWatchdog {
consecutive_indeterminate: u32,
max_indeterminate: u32,
}
impl OwnershipWatchdog {
pub fn new(max_indeterminate: u32) -> Self {
Self {
consecutive_indeterminate: 0,
max_indeterminate: max_indeterminate.max(1),
}
}
pub fn consecutive_indeterminate(&self) -> u32 {
self.consecutive_indeterminate
}
pub fn observe(
&mut self,
check: OwnershipCheck,
believed_owned: &[ReactorId],
) -> WatchdogAction {
match check {
OwnershipCheck::AllHeld => {
self.consecutive_indeterminate = 0;
WatchdogAction::Continue
}
OwnershipCheck::Lost(lost) => {
self.consecutive_indeterminate = 0;
WatchdogAction::StopReactors(lost)
}
OwnershipCheck::Indeterminate(_) => {
self.consecutive_indeterminate += 1;
if self.consecutive_indeterminate >= self.max_indeterminate {
WatchdogAction::StopAllPresumedLost(believed_owned.to_vec())
} else {
WatchdogAction::Continue
}
}
}
}
}
pub const SESSION_HELD_LOCKS_SQL: &str = "SELECT (classid::bigint << 32) | objid::bigint AS key \
FROM pg_locks \
WHERE locktype = 'advisory' AND objsubid = 1 AND granted AND pid = pg_backend_pid()";
#[cfg(feature = "postgres")]
mod session {
use super::*;
use deadpool_diesel::postgres::Manager as PgManager;
use tracing::warn;
#[derive(diesel::QueryableByName)]
struct AdvisoryLockRow {
#[diesel(sql_type = diesel::sql_types::Bool)]
locked: bool,
}
#[derive(diesel::QueryableByName)]
struct HeldKeyRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
key: i64,
}
pub struct OwnershipSession {
conn: deadpool::managed::Object<PgManager>,
state: OwnershipState,
}
impl OwnershipSession {
pub async fn connect(
db: &crate::database::Database,
) -> Result<Self, deadpool::managed::PoolError<deadpool_diesel::Error>> {
Ok(Self {
conn: db.get_postgres_connection().await?,
state: OwnershipState::new(),
})
}
pub fn state(&self) -> &OwnershipState {
&self.state
}
pub async fn claim(&mut self, id: &ReactorId) -> Result<bool, String> {
let sql = format!("SELECT pg_try_advisory_lock({}) AS locked", id.lock_key());
let acquired = self.run_lock_sql(sql).await?;
if acquired {
self.state.record_claimed(id.clone());
}
Ok(acquired)
}
pub async fn release(&mut self, id: &ReactorId) -> Result<(), String> {
let sql = format!("SELECT pg_advisory_unlock({}) AS locked", id.lock_key());
let released = self.run_lock_sql(sql).await;
self.state.record_released(id);
match released {
Ok(true) => Ok(()),
Ok(false) => {
warn!(
reactor = %id.name,
tenant = ?id.tenant,
"pg_advisory_unlock returned false — ownership had already been lost; \
forgetting it locally"
);
Ok(())
}
Err(e) => Err(e),
}
}
pub async fn verify_owned(&mut self) -> OwnershipCheck {
if self.state.is_empty() {
return OwnershipCheck::AllHeld;
}
let rows = self
.conn
.interact(|conn| {
use diesel::RunQueryDsl;
diesel::sql_query(SESSION_HELD_LOCKS_SQL).load::<HeldKeyRow>(conn)
})
.await;
let held: HashSet<i64> = match rows {
Ok(Ok(rows)) => rows.into_iter().map(|r| r.key).collect(),
Ok(Err(e)) => return OwnershipCheck::Indeterminate(format!("query failed: {e}")),
Err(e) => return OwnershipCheck::Indeterminate(format!("interact failed: {e}")),
};
let lost = self.state.diff_against_held_keys(&held);
if lost.is_empty() {
OwnershipCheck::AllHeld
} else {
for id in &lost {
self.state.record_released(id);
}
OwnershipCheck::Lost(lost)
}
}
async fn run_lock_sql(&self, sql: String) -> Result<bool, String> {
match self
.conn
.interact(move |conn| {
use diesel::RunQueryDsl;
diesel::sql_query(sql).get_result::<AdvisoryLockRow>(conn)
})
.await
{
Ok(Ok(row)) => Ok(row.locked),
Ok(Err(e)) => Err(format!("advisory lock query failed: {e}")),
Err(e) => Err(format!("interact failed: {e}")),
}
}
}
}
#[cfg(feature = "postgres")]
pub use session::OwnershipSession;
#[cfg(feature = "postgres")]
pub struct PostgresOwnership {
session: tokio::sync::Mutex<OwnershipSession>,
publication: Option<AddressPublication>,
}
#[cfg(feature = "postgres")]
pub struct AddressPublication {
pub dal: crate::dal::unified::DAL,
pub advertised_address: String,
}
#[cfg(feature = "postgres")]
impl PostgresOwnership {
pub fn new(session: OwnershipSession) -> Self {
Self {
session: tokio::sync::Mutex::new(session),
publication: None,
}
}
pub fn with_publication(mut self, publication: AddressPublication) -> Self {
self.publication = Some(publication);
self
}
async fn publish_address(&self, id: &ReactorId) {
let Some(p) = self.publication.as_ref() else {
return;
};
if let Err(e) = p
.dal
.reactor_owner_addresses()
.publish(id.tenant.as_deref(), &id.name, &p.advertised_address)
.await
{
tracing::warn!(
reactor = %id.name,
tenant = ?id.tenant,
"failed to publish owner address (injects will use the outbox fallback): {e}"
);
}
}
async fn retract_address(&self, id: &ReactorId) {
let Some(p) = self.publication.as_ref() else {
return;
};
if let Err(e) = p
.dal
.reactor_owner_addresses()
.remove_if_ours(id.tenant.as_deref(), &id.name, &p.advertised_address)
.await
{
tracing::warn!(
reactor = %id.name,
tenant = ?id.tenant,
"failed to retract owner address (a stale hint remains; costs a wasted redirect): {e}"
);
}
}
}
#[cfg(feature = "postgres")]
#[async_trait::async_trait]
impl ReactorOwnership for PostgresOwnership {
async fn claim(&self, id: &ReactorId) -> Result<bool, String> {
let won = self.session.lock().await.claim(id).await?;
if won {
self.publish_address(id).await;
}
Ok(won)
}
async fn release(&self, id: &ReactorId) -> Result<(), String> {
self.retract_address(id).await;
self.session.lock().await.release(id).await
}
async fn verify(&self) -> OwnershipCheck {
let check = self.session.lock().await.verify_owned().await;
if let OwnershipCheck::Lost(lost) = &check {
for id in lost {
self.retract_address(id).await;
}
}
check
}
async fn believed_owned(&self) -> Vec<ReactorId> {
self.session.lock().await.state().owned().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(tenant: Option<&str>, name: &str) -> ReactorId {
ReactorId::new(tenant, name)
}
#[test]
fn believed_ownership_tracks_claim_and_release() {
let mut state = OwnershipState::new();
let a = id(Some("t1"), "r1");
assert!(!state.believes_owned(&a));
state.record_claimed(a.clone());
assert!(state.believes_owned(&a));
assert_eq!(state.len(), 1);
state.record_released(&a);
assert!(!state.believes_owned(&a));
assert!(state.is_empty());
}
#[test]
fn diff_reports_nothing_lost_when_all_keys_still_held() {
let mut state = OwnershipState::new();
let a = id(Some("t1"), "r1");
let b = id(Some("t2"), "r1");
state.record_claimed(a.clone());
state.record_claimed(b.clone());
let held: HashSet<i64> = [a.lock_key(), b.lock_key()].into_iter().collect();
assert!(state.diff_against_held_keys(&held).is_empty());
}
#[test]
fn diff_reports_reactors_whose_locks_vanished() {
let mut state = OwnershipState::new();
let a = id(Some("t1"), "r1");
let b = id(Some("t2"), "r1");
state.record_claimed(a.clone());
state.record_claimed(b.clone());
let held: HashSet<i64> = [a.lock_key()].into_iter().collect();
assert_eq!(state.diff_against_held_keys(&held), vec![b]);
}
#[test]
fn diff_reports_everything_lost_when_session_holds_nothing() {
let mut state = OwnershipState::new();
let a = id(Some("t1"), "r1");
let b = id(None, "r2");
state.record_claimed(a.clone());
state.record_claimed(b.clone());
let lost = state.diff_against_held_keys(&HashSet::new());
assert_eq!(
lost.len(),
2,
"a dropped session loses everything: {lost:?}"
);
}
#[test]
fn tenants_are_independent_ownership_units() {
let mut state = OwnershipState::new();
let t1 = id(Some("t1"), "orders");
let t2 = id(Some("t2"), "orders");
state.record_claimed(t1.clone());
state.record_claimed(t2.clone());
assert_eq!(state.len(), 2, "same name in two tenants must be two units");
let held: HashSet<i64> = [t1.lock_key()].into_iter().collect();
assert_eq!(state.diff_against_held_keys(&held), vec![t2]);
}
#[test]
fn lost_ordering_is_deterministic() {
let mut state = OwnershipState::new();
for (t, n) in [
(Some("t2"), "b"),
(Some("t1"), "b"),
(Some("t1"), "a"),
(None, "z"),
] {
state.record_claimed(id(t, n));
}
let lost = state.diff_against_held_keys(&HashSet::new());
let seen: Vec<(Option<String>, String)> = lost
.iter()
.map(|r| (r.tenant.clone(), r.name.clone()))
.collect();
let mut expected = seen.clone();
expected.sort();
assert_eq!(seen, expected, "lost list must be sorted for stable logs");
}
#[test]
fn watchdog_continues_while_ownership_is_confirmed() {
let mut w = OwnershipWatchdog::new(3);
assert_eq!(
w.observe(OwnershipCheck::AllHeld, &[]),
WatchdogAction::Continue
);
assert_eq!(w.consecutive_indeterminate(), 0);
}
#[test]
fn watchdog_stops_exactly_the_reactors_reported_lost() {
let mut w = OwnershipWatchdog::new(3);
let a = id(Some("t1"), "r1");
let owned = vec![a.clone(), id(Some("t1"), "r2")];
assert_eq!(
w.observe(OwnershipCheck::Lost(vec![a.clone()]), &owned),
WatchdogAction::StopReactors(vec![a]),
"a definitive loss must stop ONLY the lost reactors, not everything"
);
}
#[test]
fn watchdog_tolerates_indeterminate_below_the_threshold() {
let mut w = OwnershipWatchdog::new(3);
let owned = vec![id(Some("t1"), "r1")];
for i in 1..3 {
assert_eq!(
w.observe(OwnershipCheck::Indeterminate("blip".into()), &owned),
WatchdogAction::Continue,
"indeterminate #{i} is below the threshold and must be tolerated"
);
}
}
#[test]
fn watchdog_presumes_loss_after_sustained_indeterminacy() {
let mut w = OwnershipWatchdog::new(3);
let owned = vec![id(Some("t1"), "r1"), id(None, "r2")];
w.observe(OwnershipCheck::Indeterminate("1".into()), &owned);
w.observe(OwnershipCheck::Indeterminate("2".into()), &owned);
assert_eq!(
w.observe(OwnershipCheck::Indeterminate("3".into()), &owned),
WatchdogAction::StopAllPresumedLost(owned.clone()),
"crossing the threshold must stop everything we believed we owned"
);
}
#[test]
fn watchdog_streak_resets_on_a_successful_check() {
let mut w = OwnershipWatchdog::new(3);
let owned = vec![id(Some("t1"), "r1")];
w.observe(OwnershipCheck::Indeterminate("1".into()), &owned);
w.observe(OwnershipCheck::Indeterminate("2".into()), &owned);
assert_eq!(w.consecutive_indeterminate(), 2);
w.observe(OwnershipCheck::AllHeld, &owned);
assert_eq!(
w.consecutive_indeterminate(),
0,
"success must clear the streak"
);
assert_eq!(
w.observe(OwnershipCheck::Indeterminate("3".into()), &owned),
WatchdogAction::Continue
);
assert_eq!(
w.observe(OwnershipCheck::Indeterminate("4".into()), &owned),
WatchdogAction::Continue
);
}
#[test]
fn watchdog_streak_resets_on_a_definitive_loss() {
let mut w = OwnershipWatchdog::new(2);
let a = id(Some("t1"), "r1");
w.observe(OwnershipCheck::Indeterminate("1".into()), &[a.clone()]);
w.observe(OwnershipCheck::Lost(vec![a.clone()]), &[a.clone()]);
assert_eq!(w.consecutive_indeterminate(), 0);
}
#[test]
fn watchdog_threshold_is_clamped_to_at_least_one() {
let mut w = OwnershipWatchdog::new(0);
let owned = vec![id(Some("t1"), "r1")];
assert_eq!(
w.observe(OwnershipCheck::Indeterminate("x".into()), &owned),
WatchdogAction::StopAllPresumedLost(owned),
"clamped to 1: still stops, but on a defined threshold rather than \
a divide-by-zero-ish 0"
);
}
#[test]
fn held_locks_sql_is_scoped_to_this_session() {
assert!(
SESSION_HELD_LOCKS_SQL.contains("pid = pg_backend_pid()"),
"liveness check MUST be scoped to this session, else another \
replica's lock reads as our own"
);
assert!(SESSION_HELD_LOCKS_SQL.contains("granted"));
assert!(SESSION_HELD_LOCKS_SQL.contains("objsubid = 1"));
}
}