use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use crate::bulkhead::{TenantBulkhead, TenantPermit, shuffle_shard_contains};
pub struct FairBucket<T> {
ring: VecDeque<Option<String>>,
jobs: HashMap<Option<String>, VecDeque<T>>,
len: usize,
}
impl<T> FairBucket<T> {
pub fn new() -> Self {
Self {
ring: VecDeque::new(),
jobs: HashMap::new(),
len: 0,
}
}
pub const fn len(&self) -> usize {
self.len
}
pub fn push(&mut self, tenant: Option<String>, item: T) {
#[allow(clippy::option_if_let_else)] let queue = match self.jobs.get_mut(&tenant) {
Some(queue) => queue,
None => self.jobs.entry(tenant.clone()).or_default(),
};
if queue.is_empty() {
self.ring.push_back(tenant);
}
queue.push_back(item);
self.len = self.len.saturating_add(1);
}
pub fn pop_where<P>(
&mut self,
mut admit: impl FnMut(Option<&str>) -> Option<P>,
) -> Option<(T, P)> {
for _ in 0..self.ring.len() {
let tenant = self.ring.pop_front()?;
let Some(permit) = admit(tenant.as_deref()) else {
self.ring.push_back(tenant);
continue;
};
let queue = self.jobs.get_mut(&tenant)?;
let item = queue.pop_front()?;
if queue.is_empty() {
self.jobs.remove(&tenant);
} else {
self.ring.push_back(tenant);
}
self.len = self.len.saturating_sub(1);
return Some((item, permit));
}
None
}
}
pub struct TenantJobIsolation {
slots: Arc<TenantBulkhead>,
lanes: u16,
lanes_per_tenant: u16,
}
pub struct JobAdmit {
#[allow(dead_code, reason = "held for its drop, which gives the slot back")]
permit: Option<TenantPermit>,
}
impl JobAdmit {
#[cfg(test)]
const fn holds_slot(&self) -> bool {
self.permit.is_some()
}
}
impl TenantJobIsolation {
pub fn from_config(
config: &crate::config::JobTenantsConfig,
workers: usize,
) -> Option<Arc<Self>> {
if config.max_concurrent == 0 && config.lanes == 0 {
return None;
}
let max_lanes = u16::try_from(workers.max(1)).unwrap_or(u16::MAX);
let lanes = config.lanes.min(max_lanes);
if lanes < config.lanes {
tracing::warn!(
lanes = config.lanes,
workers,
"jobs.tenants.lanes is more than jobs.workers; using {lanes} lanes"
);
}
Some(Arc::new(Self {
slots: TenantBulkhead::new(config.max_concurrent),
lanes,
lanes_per_tenant: config.lanes_per_tenant.max(1),
}))
}
pub fn lane_of_worker(&self, worker: usize) -> Option<u16> {
if self.lanes == 0 {
return None;
}
let lane = worker.checked_rem(usize::from(self.lanes)).unwrap_or(0);
u16::try_from(lane).ok()
}
pub fn try_admit(&self, tenant: Option<&str>, lane: Option<u16>) -> Option<JobAdmit> {
let Some(tenant) = tenant else {
return Some(JobAdmit { permit: None });
};
if let Some(lane) = lane
&& !shuffle_shard_contains(tenant, self.lanes, self.lanes_per_tenant, lane)
{
return None;
}
self.slots.try_acquire(tenant).map(|permit| JobAdmit {
permit: Some(permit),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::JobTenantsConfig;
fn take_all(bucket: &mut FairBucket<&'static str>) -> Vec<&'static str> {
let mut out = Vec::new();
while let Some((item, ())) = bucket.pop_where(|_| Some(())) {
out.push(item);
}
out
}
#[test]
fn fair_bucket_serves_tenants_round_robin() {
let mut bucket = FairBucket::new();
for item in ["n1", "n2", "n3", "n4"] {
bucket.push(Some("noisy".into()), item);
}
bucket.push(Some("quiet".into()), "q1");
bucket.push(None, "s1");
assert_eq!(bucket.len(), 6);
assert_eq!(take_all(&mut bucket), ["n1", "q1", "s1", "n2", "n3", "n4"]);
assert_eq!(bucket.len(), 0);
}
#[test]
fn fair_bucket_skips_a_tenant_that_is_not_admitted() {
let mut bucket = FairBucket::new();
bucket.push(Some("noisy".into()), "n1");
bucket.push(Some("quiet".into()), "q1");
let popped = bucket.pop_where(|t| (t != Some("noisy")).then_some(()));
assert_eq!(popped.map(|(item, ())| item), Some("q1"));
assert!(
bucket
.pop_where(|t| (t != Some("noisy")).then_some(()))
.is_none()
);
assert_eq!(bucket.len(), 1, "the skipped job stays");
}
fn config(max_concurrent: usize, lanes: u16, lanes_per_tenant: u16) -> JobTenantsConfig {
JobTenantsConfig {
max_concurrent,
lanes,
lanes_per_tenant,
}
}
#[test]
fn isolation_is_off_by_default() {
assert!(TenantJobIsolation::from_config(&JobTenantsConfig::default(), 4).is_none());
}
#[test]
fn the_cap_applies_per_tenant_and_not_to_untenanted_jobs() {
let isolation = TenantJobIsolation::from_config(&config(1, 0, 0), 4).expect("on");
assert_eq!(isolation.lane_of_worker(3), None, "no lanes");
let held = isolation.try_admit(Some("a"), None).expect("first slot");
assert!(held.holds_slot(), "a tenant job holds a slot");
assert!(
isolation.try_admit(Some("a"), None).is_none(),
"a is at its cap"
);
assert!(
isolation.try_admit(Some("b"), None).is_some(),
"b has its own cap"
);
assert!(
isolation
.try_admit(None, None)
.is_some_and(|admit| !admit.holds_slot()),
"untenanted jobs are not capped"
);
drop(held);
assert!(isolation.try_admit(Some("a"), None).is_some());
}
#[test]
fn a_worker_runs_only_tenants_whose_shard_holds_its_lane() {
let isolation = TenantJobIsolation::from_config(&config(0, 4, 1), 8).expect("on");
let lane = crate::bulkhead::shuffle_shard("a", 4, 1)[0];
let workers: Vec<usize> = (0..8)
.filter(|&w| isolation.lane_of_worker(w) == Some(lane))
.collect();
assert_eq!(workers.len(), 2, "8 workers on 4 lanes: 2 per lane");
for worker in 0..8 {
let admitted = isolation
.try_admit(Some("a"), isolation.lane_of_worker(worker))
.is_some();
assert_eq!(admitted, workers.contains(&worker), "worker {worker}");
}
assert!(
isolation.try_admit(None, Some((lane + 1) % 4)).is_some(),
"untenanted jobs run on every lane"
);
}
#[test]
fn every_tenant_has_a_worker_when_lanes_exceed_workers() {
let isolation = TenantJobIsolation::from_config(&config(0, 8, 2), 1).expect("on");
let lane = isolation.lane_of_worker(0);
for i in 0..100 {
assert!(
isolation.try_admit(Some(&format!("t{i}")), lane).is_some(),
"tenant t{i} has no worker"
);
}
}
}