use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::cluster::{ClusterEpoch, ClusterMember};
use crate::grid::elasticity::{RegionId, ZoneAwareReplicaSet, ZoneAwareReplicationStrategy};
pub const RESIDENCY_POLICY_FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyPolicy {
pub format_version: u32,
pub epoch: ClusterEpoch,
allowed_regions: BTreeSet<RegionId>,
min_replicas_in_policy: usize,
}
impl ResidencyPolicy {
pub fn new<R>(
allowed_regions: impl IntoIterator<Item = R>,
min_replicas_in_policy: usize,
epoch: ClusterEpoch,
) -> Result<Self, ResidencyPolicyError>
where
R: Into<RegionId>,
{
let allowed_regions = allowed_regions.into_iter().map(Into::into).collect();
let policy = Self {
format_version: RESIDENCY_POLICY_FORMAT_VERSION,
epoch,
allowed_regions,
min_replicas_in_policy: min_replicas_in_policy.max(1),
};
policy.validate()?;
Ok(policy)
}
pub fn with_format_version(mut self, format_version: u32) -> Self {
self.format_version = format_version;
self
}
pub fn allowed_regions(&self) -> &BTreeSet<RegionId> {
&self.allowed_regions
}
pub fn min_replicas_in_policy(&self) -> usize {
self.min_replicas_in_policy
}
pub fn allows_region(&self, region: &RegionId) -> bool {
self.allowed_regions.contains(region)
}
fn validate(&self) -> Result<(), ResidencyPolicyError> {
if self.format_version > RESIDENCY_POLICY_FORMAT_VERSION {
return Err(ResidencyPolicyError::new(format!(
"residency policy format {} is newer than supported {}",
self.format_version, RESIDENCY_POLICY_FORMAT_VERSION
)));
}
if self.allowed_regions.is_empty() {
return Err(ResidencyPolicyError::new(
"residency policy must contain at least one allowed region",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResidencyPolicyError {
message: String,
}
impl ResidencyPolicyError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for ResidencyPolicyError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ResidencyPolicyError {}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyPolicySet {
epoch: ClusterEpoch,
namespace_policies: BTreeMap<String, ResidencyPolicy>,
key_overrides: BTreeMap<String, BTreeMap<String, ResidencyPolicy>>,
}
impl ResidencyPolicySet {
pub fn new() -> Self {
Self::default()
}
pub fn epoch(&self) -> ClusterEpoch {
self.epoch
}
pub fn commit_namespace_policy(
&mut self,
namespace: impl Into<String>,
policy: ResidencyPolicy,
) -> Result<(), ResidencyPolicyError> {
let namespace = normalize_non_empty("namespace", namespace.into())?;
self.commit_policy_epoch(&policy)?;
self.epoch = self.epoch.max(policy.epoch);
self.namespace_policies.insert(namespace, policy);
Ok(())
}
pub fn commit_key_override(
&mut self,
namespace: impl Into<String>,
key: impl Into<String>,
policy: ResidencyPolicy,
) -> Result<(), ResidencyPolicyError> {
let namespace = normalize_non_empty("namespace", namespace.into())?;
let key = normalize_non_empty("key", key.into())?;
self.commit_policy_epoch(&policy)?;
self.epoch = self.epoch.max(policy.epoch);
self.key_overrides
.entry(namespace)
.or_default()
.insert(key, policy);
Ok(())
}
pub fn policy_for(&self, namespace: &str, key: &str) -> Option<&ResidencyPolicy> {
self.key_overrides
.get(namespace)
.and_then(|keys| keys.get(key))
.or_else(|| self.namespace_policies.get(namespace))
}
pub fn effective_epoch(&self, namespace: &str, key: &str) -> ClusterEpoch {
self.policy_for(namespace, key)
.map(|policy| policy.epoch)
.unwrap_or(self.epoch)
}
fn commit_policy_epoch(&self, policy: &ResidencyPolicy) -> Result<(), ResidencyPolicyError> {
policy.validate()?;
if policy.epoch < self.epoch {
return Err(ResidencyPolicyError::new(format!(
"residency policy epoch {} is older than committed epoch {}",
policy.epoch.value(),
self.epoch.value()
)));
}
Ok(())
}
}
fn normalize_non_empty(field: &'static str, value: String) -> Result<String, ResidencyPolicyError> {
let value = value.trim().to_owned();
if value.is_empty() {
return Err(ResidencyPolicyError::new(format!(
"residency policy {field} must not be empty"
)));
}
Ok(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResidencyRejectionKind {
RejectPlacement,
RefuseCrossBoundary,
RejectRead,
StalePolicyEpoch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyRejection {
pub kind: ResidencyRejectionKind,
pub namespace: String,
pub key: String,
pub policy_epoch: ClusterEpoch,
pub source_region: Option<RegionId>,
pub target_region: Option<RegionId>,
pub reason: String,
}
impl ResidencyRejection {
fn reject_placement(
namespace: &str,
key: &str,
policy_epoch: ClusterEpoch,
reason: impl Into<String>,
) -> Self {
Self {
kind: ResidencyRejectionKind::RejectPlacement,
namespace: namespace.to_owned(),
key: key.to_owned(),
policy_epoch,
source_region: None,
target_region: None,
reason: reason.into(),
}
}
fn refuse_cross_boundary(
namespace: &str,
key: &str,
policy_epoch: ClusterEpoch,
source_region: &RegionId,
target_region: &RegionId,
) -> Self {
Self {
kind: ResidencyRejectionKind::RefuseCrossBoundary,
namespace: namespace.to_owned(),
key: key.to_owned(),
policy_epoch,
source_region: Some(source_region.clone()),
target_region: Some(target_region.clone()),
reason: format!(
"value movement from {} to {} is forbidden by residency policy",
source_region.as_str(),
target_region.as_str()
),
}
}
fn reject_read(
namespace: &str,
key: &str,
policy_epoch: ClusterEpoch,
serving_region: &RegionId,
) -> Self {
Self {
kind: ResidencyRejectionKind::RejectRead,
namespace: namespace.to_owned(),
key: key.to_owned(),
policy_epoch,
source_region: None,
target_region: Some(serving_region.clone()),
reason: format!(
"serving value bytes from {} is forbidden by residency policy",
serving_region.as_str()
),
}
}
fn stale_epoch(
namespace: &str,
key: &str,
policy_epoch: ClusterEpoch,
observed_epoch: ClusterEpoch,
) -> Self {
Self {
kind: ResidencyRejectionKind::StalePolicyEpoch,
namespace: namespace.to_owned(),
key: key.to_owned(),
policy_epoch,
source_region: None,
target_region: None,
reason: format!(
"observed residency policy epoch {} is older than current epoch {}",
observed_epoch.value(),
policy_epoch.value()
),
}
}
}
impl fmt::Display for ResidencyRejection {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.reason)
}
}
impl std::error::Error for ResidencyRejection {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResidencyDecision {
Allow {
policy_epoch: ClusterEpoch,
},
Reject(ResidencyRejection),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyMetricsSnapshot {
pub residency_rejected_placement_total: u64,
pub residency_refused_crossing_total: u64,
pub residency_rejected_read_total: u64,
pub residency_policy_narrowing_out_of_policy_total: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResidencyAuditAction {
RejectPlacement,
RefuseCrossBoundary,
RejectRead,
PolicyNarrowingRemediation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyAuditEvent {
pub action: ResidencyAuditAction,
pub namespace: String,
pub key: String,
pub policy_epoch: ClusterEpoch,
pub source_region: Option<RegionId>,
pub target_region: Option<RegionId>,
pub reason: String,
}
impl ResidencyAuditEvent {
fn from_rejection(rejection: &ResidencyRejection) -> Self {
let action = match rejection.kind {
ResidencyRejectionKind::RejectPlacement => ResidencyAuditAction::RejectPlacement,
ResidencyRejectionKind::RefuseCrossBoundary => {
ResidencyAuditAction::RefuseCrossBoundary
}
ResidencyRejectionKind::RejectRead | ResidencyRejectionKind::StalePolicyEpoch => {
ResidencyAuditAction::RejectRead
}
};
Self {
action,
namespace: rejection.namespace.clone(),
key: rejection.key.clone(),
policy_epoch: rejection.policy_epoch,
source_region: rejection.source_region.clone(),
target_region: rejection.target_region.clone(),
reason: rejection.reason.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyPolicyEnforcer {
policies: ResidencyPolicySet,
metrics: ResidencyMetricsSnapshot,
audit_events: Vec<ResidencyAuditEvent>,
}
impl ResidencyPolicyEnforcer {
pub fn new(policies: ResidencyPolicySet) -> Self {
Self {
policies,
metrics: ResidencyMetricsSnapshot::default(),
audit_events: Vec::new(),
}
}
pub fn policies(&self) -> &ResidencyPolicySet {
&self.policies
}
pub fn metrics(&self) -> ResidencyMetricsSnapshot {
self.metrics
}
pub fn audit_events(&self) -> &[ResidencyAuditEvent] {
&self.audit_events
}
pub fn place_key(
&mut self,
strategy: &ZoneAwareReplicationStrategy,
namespace: &str,
key: &str,
members: &[ClusterMember],
) -> Result<ZoneAwareReplicaSet, Box<ResidencyRejection>> {
if let Some(policy) = self.policies.policy_for(namespace, key) {
let required = strategy
.replication_factor()
.max(policy.min_replicas_in_policy());
if let Some(replicas) = strategy.zone_replicas_for_key_in_regions(
key,
members,
policy.allowed_regions(),
required,
) {
return Ok(replicas);
}
let rejection = ResidencyRejection::reject_placement(
namespace,
key,
policy.epoch,
format!("replication factor {required} cannot be satisfied inside allowed regions"),
);
self.record_rejection(rejection.clone());
return Err(Box::new(rejection));
}
strategy.zone_replicas_for_key(key, members).ok_or_else(|| {
let rejection = ResidencyRejection::reject_placement(
namespace,
key,
self.policies.effective_epoch(namespace, key),
"no members available for placement",
);
self.record_rejection(rejection.clone());
Box::new(rejection)
})
}
pub fn guard_cross_boundary(
&mut self,
namespace: &str,
key: &str,
source_region: &RegionId,
target_region: &RegionId,
) -> Result<ResidencyDecision, Box<ResidencyRejection>> {
let Some(policy) = self.policies.policy_for(namespace, key) else {
return Ok(ResidencyDecision::Allow {
policy_epoch: self.policies.effective_epoch(namespace, key),
});
};
if policy.allows_region(target_region) {
return Ok(ResidencyDecision::Allow {
policy_epoch: policy.epoch,
});
}
let rejection = ResidencyRejection::refuse_cross_boundary(
namespace,
key,
policy.epoch,
source_region,
target_region,
);
self.record_rejection(rejection.clone());
Err(Box::new(rejection))
}
pub fn guard_read(
&mut self,
namespace: &str,
key: &str,
serving_region: &RegionId,
observed_epoch: ClusterEpoch,
) -> Result<ResidencyDecision, Box<ResidencyRejection>> {
let Some(policy) = self.policies.policy_for(namespace, key) else {
return Ok(ResidencyDecision::Allow {
policy_epoch: self.policies.effective_epoch(namespace, key),
});
};
if observed_epoch < policy.epoch {
let rejection =
ResidencyRejection::stale_epoch(namespace, key, policy.epoch, observed_epoch);
self.record_rejection(rejection.clone());
return Err(Box::new(rejection));
}
if policy.allows_region(serving_region) {
return Ok(ResidencyDecision::Allow {
policy_epoch: policy.epoch,
});
}
let rejection =
ResidencyRejection::reject_read(namespace, key, policy.epoch, serving_region);
self.record_rejection(rejection.clone());
Err(Box::new(rejection))
}
pub fn include_value_allowed(&self, namespace: &str, key: &str, region: &RegionId) -> bool {
self.policies
.policy_for(namespace, key)
.map(|policy| policy.allows_region(region))
.unwrap_or(true)
}
pub fn plan_policy_narrowing(
&mut self,
locations: impl IntoIterator<Item = ResidencyValueLocation>,
) -> ResidencyNarrowingReport {
let mut actions = Vec::new();
for location in locations {
let policy = self
.policies
.policy_for(&location.namespace, &location.key)
.cloned();
match policy {
Some(policy) if !policy.allows_region(&location.region) => {
self.metrics.residency_policy_narrowing_out_of_policy_total = self
.metrics
.residency_policy_narrowing_out_of_policy_total
.saturating_add(1);
let action = ResidencyRemediationAction::Evict {
namespace: location.namespace.clone(),
key: location.key.clone(),
region: location.region.clone(),
policy_epoch: policy.epoch,
};
self.audit_events.push(ResidencyAuditEvent {
action: ResidencyAuditAction::PolicyNarrowingRemediation,
namespace: location.namespace,
key: location.key,
policy_epoch: policy.epoch,
source_region: Some(location.region),
target_region: None,
reason: "existing value location is outside narrowed residency policy"
.to_owned(),
});
actions.push(action);
}
Some(policy) => actions.push(ResidencyRemediationAction::Keep {
namespace: location.namespace,
key: location.key,
region: location.region,
policy_epoch: policy.epoch,
}),
None => actions.push(ResidencyRemediationAction::Keep {
namespace: location.namespace,
key: location.key,
region: location.region,
policy_epoch: self.policies.epoch(),
}),
}
}
ResidencyNarrowingReport { actions }
}
pub fn choose_failover_home(
&self,
namespace: &str,
key: &str,
surviving_regions: impl IntoIterator<Item = RegionId>,
) -> ResidencyFailoverDecision {
let policy = self.policies.policy_for(namespace, key);
for region in surviving_regions {
if policy
.map(|policy| policy.allows_region(®ion))
.unwrap_or(true)
{
return ResidencyFailoverDecision::Promote {
target_region: region,
policy_epoch: policy
.map(|policy| policy.epoch)
.unwrap_or_else(|| self.policies.effective_epoch(namespace, key)),
};
}
}
ResidencyFailoverDecision::Degraded {
policy_epoch: self.policies.effective_epoch(namespace, key),
reason: "no surviving region is allowed by residency policy".to_owned(),
}
}
fn record_rejection(&mut self, rejection: ResidencyRejection) {
match rejection.kind {
ResidencyRejectionKind::RejectPlacement => {
self.metrics.residency_rejected_placement_total = self
.metrics
.residency_rejected_placement_total
.saturating_add(1);
}
ResidencyRejectionKind::RefuseCrossBoundary => {
self.metrics.residency_refused_crossing_total = self
.metrics
.residency_refused_crossing_total
.saturating_add(1);
}
ResidencyRejectionKind::RejectRead | ResidencyRejectionKind::StalePolicyEpoch => {
self.metrics.residency_rejected_read_total =
self.metrics.residency_rejected_read_total.saturating_add(1);
}
}
self.audit_events
.push(ResidencyAuditEvent::from_rejection(&rejection));
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyValueLocation {
pub namespace: String,
pub key: String,
pub region: RegionId,
}
impl ResidencyValueLocation {
pub fn new(
namespace: impl Into<String>,
key: impl Into<String>,
region: impl Into<RegionId>,
) -> Self {
Self {
namespace: namespace.into(),
key: key.into(),
region: region.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResidencyRemediationAction {
Keep {
namespace: String,
key: String,
region: RegionId,
policy_epoch: ClusterEpoch,
},
Evict {
namespace: String,
key: String,
region: RegionId,
policy_epoch: ClusterEpoch,
},
MarkDegraded {
namespace: String,
key: String,
region: RegionId,
policy_epoch: ClusterEpoch,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyNarrowingReport {
pub actions: Vec<ResidencyRemediationAction>,
}
impl ResidencyNarrowingReport {
pub fn has_remediation(&self) -> bool {
self.actions.iter().any(|action| {
matches!(
action,
ResidencyRemediationAction::Evict { .. }
| ResidencyRemediationAction::MarkDegraded { .. }
)
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResidencyLinkSendReport {
pub checked: u64,
pub refused: u64,
pub sent: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResidencyFailoverDecision {
Promote {
target_region: RegionId,
policy_epoch: ClusterEpoch,
},
Degraded {
policy_epoch: ClusterEpoch,
reason: String,
},
}