use std::collections::BTreeMap;
use aion_store::{ClaimScope, OutboxRow, OutboxStore};
use std::sync::Arc;
use tracing::warn;
use crate::worker::QuotaCache;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OwnedShardFraction {
owned: u32,
total: u32,
}
impl OwnedShardFraction {
#[must_use]
pub fn new(owned: u32, total: u32) -> Self {
let total = total.max(1);
let owned = owned.clamp(1, total);
Self { owned, total }
}
#[must_use]
pub fn own_all() -> Self {
Self { owned: 1, total: 1 }
}
#[must_use]
pub fn per_node_ceiling(self, quota: u32) -> u32 {
let numerator = u64::from(quota) * u64::from(self.owned) + u64::from(self.total) - 1;
let ceiling = numerator / u64::from(self.total);
u32::try_from(ceiling).unwrap_or(u32::MAX)
}
}
#[derive(Clone, Debug)]
struct NamespacePlan {
headroom: u32,
routes: Vec<ClaimScope>,
}
#[derive(Clone)]
pub struct Backpressure {
quota: QuotaCache,
fraction: OwnedShardFraction,
}
impl std::fmt::Debug for Backpressure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Backpressure")
.field("fraction", &self.fraction)
.finish_non_exhaustive()
}
}
impl Backpressure {
#[must_use]
pub fn new(quota: QuotaCache, fraction: OwnedShardFraction) -> Self {
Self { quota, fraction }
}
pub async fn claim_round_robin(
&self,
store: &Arc<dyn OutboxStore>,
batch_size: u32,
held: &std::collections::HashSet<aion_core::WorkflowId>,
) -> Result<Vec<OutboxRow>, aion_store::StoreError> {
let routes = store.pending_outbox_routes().await?;
if routes.is_empty() {
return Ok(Vec::new());
}
let plan = self.plan_sweep(store, &routes, batch_size).await?;
self.execute_plan(store, &plan, batch_size, held).await
}
async fn plan_sweep(
&self,
store: &Arc<dyn OutboxStore>,
routes: &[ClaimScope],
batch_size: u32,
) -> Result<SweepPlan, aion_store::StoreError> {
let mut namespaces: BTreeMap<String, NamespacePlan> = BTreeMap::new();
for route in routes {
namespaces
.entry(route.namespace.clone())
.or_insert_with(|| NamespacePlan {
headroom: 0,
routes: Vec::new(),
})
.routes
.push(route.clone());
}
let names: Vec<&str> = namespaces.keys().map(String::as_str).collect();
let claimed_by_namespace = store.count_claimed_outbox_rows_by_namespace(&names).await?;
for (namespace, plan) in &mut namespaces {
let ceiling = self
.fraction
.per_node_ceiling(self.quota.ceiling(namespace).await);
let claimed = u32::try_from(claimed_by_namespace.get(namespace).copied().unwrap_or(0))
.unwrap_or(u32::MAX);
plan.headroom = ceiling.saturating_sub(claimed);
}
let active = u32::try_from(namespaces.len()).unwrap_or(u32::MAX).max(1);
let per_namespace_slice = batch_size.div_ceil(active).max(1);
Ok(SweepPlan {
namespaces,
per_namespace_slice,
})
}
async fn execute_plan(
&self,
store: &Arc<dyn OutboxStore>,
plan: &SweepPlan,
batch_size: u32,
held: &std::collections::HashSet<aion_core::WorkflowId>,
) -> Result<Vec<OutboxRow>, aion_store::StoreError> {
let mut headroom: BTreeMap<&str, u32> = plan
.namespaces
.iter()
.map(|(name, ns)| (name.as_str(), ns.headroom))
.collect();
let mut budget = batch_size;
let mut claimed = Vec::new();
for (name, ns) in &plan.namespaces {
let ns_headroom = headroom.entry(name.as_str()).or_default();
let allocation = plan.per_namespace_slice.min(*ns_headroom).min(budget);
let got =
Self::claim_namespace_slice(store, &ns.routes, allocation, &mut claimed, held)
.await?;
*ns_headroom = ns_headroom.saturating_sub(got);
budget = budget.saturating_sub(got);
}
for (name, ns) in &plan.namespaces {
if budget == 0 {
break;
}
let ns_headroom = headroom.entry(name.as_str()).or_default();
let allocation = (*ns_headroom).min(budget);
let got =
Self::claim_namespace_slice(store, &ns.routes, allocation, &mut claimed, held)
.await?;
*ns_headroom = ns_headroom.saturating_sub(got);
budget = budget.saturating_sub(got);
}
if claimed.is_empty() && !plan.namespaces.is_empty() {
warn!("outbox backpressure held all pending routes at ceiling this sweep");
}
Ok(claimed)
}
async fn claim_namespace_slice(
store: &Arc<dyn OutboxStore>,
routes: &[ClaimScope],
allocation: u32,
claimed: &mut Vec<OutboxRow>,
held: &std::collections::HashSet<aion_core::WorkflowId>,
) -> Result<u32, aion_store::StoreError> {
let mut remaining = allocation;
let mut total: u32 = 0;
let mut left = u32::try_from(routes.len()).unwrap_or(u32::MAX).max(1);
for route in routes {
if remaining == 0 {
break;
}
let share = remaining.div_ceil(left).max(1).min(remaining);
let rows = store
.claim_outbox_rows_scoped_excluding(route, share, held)
.await?;
let got = u32::try_from(rows.len()).unwrap_or(u32::MAX);
remaining = remaining.saturating_sub(got);
total = total.saturating_add(got);
claimed.extend(rows);
left = left.saturating_sub(1).max(1);
}
Ok(total)
}
}
struct SweepPlan {
namespaces: BTreeMap<String, NamespacePlan>,
per_namespace_slice: u32,
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use aion_store::{ClaimScope, OutboxRow, OutboxStatus, OutboxStore, StoreError};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use super::{Backpressure, OwnedShardFraction};
use crate::worker::QuotaCache;
struct CountingStore {
rows: Vec<OutboxRow>,
scalar_count_calls: AtomicUsize,
bucketed_count_calls: AtomicUsize,
}
impl CountingStore {
fn new(rows: Vec<OutboxRow>) -> Self {
Self {
rows,
scalar_count_calls: AtomicUsize::new(0),
bucketed_count_calls: AtomicUsize::new(0),
}
}
fn claimed_in(&self, namespace: &str) -> u64 {
let count = self
.rows
.iter()
.filter(|row| {
row.namespace == namespace && matches!(row.status, OutboxStatus::Claimed)
})
.count();
u64::try_from(count).unwrap_or(u64::MAX)
}
}
#[async_trait]
impl OutboxStore for CountingStore {
async fn append_outbox_batch(&self, _rows: &[OutboxRow]) -> Result<(), StoreError> {
Ok(())
}
async fn claim_outbox_rows(&self, _limit: u32) -> Result<Vec<OutboxRow>, StoreError> {
Ok(Vec::new())
}
async fn claim_outbox_rows_scoped(
&self,
_scope: &ClaimScope,
_limit: u32,
) -> Result<Vec<OutboxRow>, StoreError> {
Ok(Vec::new())
}
async fn rearm_stale_claimed_outbox_rows(
&self,
_older_than: DateTime<Utc>,
_visible_after: DateTime<Utc>,
_limit: u32,
) -> Result<Vec<OutboxRow>, StoreError> {
Ok(Vec::new())
}
async fn complete_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
Ok(())
}
async fn retry_outbox_row(
&self,
_dispatch_key: &str,
_next_attempt: u32,
_visible_after: DateTime<Utc>,
) -> Result<(), StoreError> {
Ok(())
}
async fn fail_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
Ok(())
}
async fn count_inflight_outbox_rows(&self, _namespace: &str) -> Result<u64, StoreError> {
Ok(0)
}
async fn count_claimed_outbox_rows(&self, namespace: &str) -> Result<u64, StoreError> {
self.scalar_count_calls.fetch_add(1, Ordering::SeqCst);
Ok(self.claimed_in(namespace))
}
async fn count_claimed_outbox_rows_by_namespace(
&self,
namespaces: &[&str],
) -> Result<std::collections::BTreeMap<String, u64>, StoreError> {
self.bucketed_count_calls.fetch_add(1, Ordering::SeqCst);
Ok(namespaces
.iter()
.map(|ns| ((*ns).to_owned(), self.claimed_in(ns)))
.collect())
}
async fn pending_outbox_routes(&self) -> Result<Vec<ClaimScope>, StoreError> {
Ok(Vec::new())
}
}
fn quota_cache() -> QuotaCache {
let store: Arc<dyn aion_store::NamespaceStore> =
Arc::new(aion_store::InMemoryStore::default());
QuotaCache::new(store, 100, Duration::from_secs(60))
}
fn claimed_row(namespace: &str, task_queue: &str) -> OutboxRow {
let now = Utc::now();
let mut row = OutboxRow::pending(
aion_core::WorkflowId::new_v4(),
0,
"act".to_owned(),
aion_core::Payload::new(aion_core::ContentType::Json, Vec::new()),
now,
)
.with_namespace(namespace)
.with_task_queue(task_queue);
row.status = OutboxStatus::Claimed;
row
}
#[tokio::test]
async fn collapsed_scan_yields_identical_namespace_buckets_and_one_scan() {
let rows = vec![
claimed_row("alpha", "q1"),
claimed_row("alpha", "q2"),
claimed_row("alpha", "q1"),
claimed_row("beta", "q1"),
claimed_row("gamma", "q1"),
claimed_row("gamma", "q2"),
];
let counting = Arc::new(CountingStore::new(rows));
let store: Arc<dyn OutboxStore> = Arc::clone(&counting) as Arc<dyn OutboxStore>;
let bp = Backpressure::new(quota_cache(), OwnedShardFraction::own_all());
let routes = vec![
ClaimScope::new("alpha", "q1"),
ClaimScope::new("alpha", "q2"),
ClaimScope::new("beta", "q1"),
ClaimScope::new("gamma", "q1"),
ClaimScope::new("gamma", "q2"),
];
let expected: std::collections::BTreeMap<&str, u32> =
[("alpha", 100 - 3), ("beta", 100 - 1), ("gamma", 100 - 2)]
.into_iter()
.collect();
let plan = bp
.plan_sweep(&store, &routes, 64)
.await
.expect("plan resolves");
for (name, ns) in &plan.namespaces {
assert_eq!(
ns.headroom,
expected[name.as_str()],
"namespace {name} headroom must match the per-namespace-scan result"
);
}
assert_eq!(plan.namespaces.len(), 3, "one bucket per active namespace");
assert_eq!(
counting.bucketed_count_calls.load(Ordering::SeqCst),
1,
"the owned-shard set is scanned exactly once for all namespaces"
);
assert_eq!(
counting.scalar_count_calls.load(Ordering::SeqCst),
0,
"the collapsed path never falls back to the N per-namespace scans"
);
}
#[test]
fn own_all_ceiling_equals_full_quota() {
let fraction = OwnedShardFraction::own_all();
assert_eq!(fraction.per_node_ceiling(256), 256);
assert_eq!(fraction.per_node_ceiling(0), 0);
assert_eq!(fraction.per_node_ceiling(1), 1);
}
#[test]
fn proportional_ceiling_is_owned_fraction_of_quota_rounded_up() {
let quarter = OwnedShardFraction::new(2, 8);
assert_eq!(quarter.per_node_ceiling(256), 64, "256 × 2/8 = 64");
assert_eq!(quarter.per_node_ceiling(100), 25, "100 × 2/8 = 25");
assert_eq!(
quarter.per_node_ceiling(10),
3,
"ceil(10 × 2/8) = ceil(2.5) = 3"
);
}
#[test]
fn per_node_ceilings_sum_to_at_least_the_cluster_quota() {
let quota = 100;
let node = OwnedShardFraction::new(2, 8);
let per_node = node.per_node_ceiling(quota);
assert!(
u64::from(per_node) * 4 >= u64::from(quota),
"4 × 25 = 100 >= 100"
);
}
#[test]
fn fraction_clamps_degenerate_inputs() {
assert_eq!(OwnedShardFraction::new(0, 0).per_node_ceiling(64), 64);
assert_eq!(
OwnedShardFraction::new(9, 4).per_node_ceiling(64),
64,
"owned > total clamps to 1"
);
}
}