#[cfg(feature = "loom")]
use loom::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(feature = "loom"))]
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tensor_wasm_core::error::TensorWasmError;
use tensor_wasm_core::mem_pool::DriverMemPool;
use tensor_wasm_core::metrics::{TenantLabels, TensorWasmMetrics};
use tensor_wasm_core::types::TenantId;
#[cfg(not(feature = "loom"))]
static ISOLATION_DOWNGRADE_COUNT: AtomicU64 = AtomicU64::new(0);
#[cfg(not(feature = "loom"))]
pub fn isolation_downgrade_count() -> u64 {
ISOLATION_DOWNGRADE_COUNT.load(Ordering::Relaxed)
}
#[cfg(feature = "loom")]
pub fn isolation_downgrade_count() -> u64 {
0
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IsolationKind {
Shared,
#[default]
StreamIsolated,
ContextIsolated,
}
impl IsolationKind {
pub fn name(self) -> &'static str {
match self {
IsolationKind::Shared => "shared",
IsolationKind::StreamIsolated => "stream_isolated",
IsolationKind::ContextIsolated => "context_isolated",
}
}
}
impl std::fmt::Display for IsolationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RateLimited {
pub requested: u64,
pub available: u64,
pub ops_per_sec: u64,
pub burst: u64,
}
impl std::fmt::Display for RateLimited {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"rate limited: requested {} ops, {} available (rate {}/s, burst {})",
self.requested, self.available, self.ops_per_sec, self.burst
)
}
}
impl std::error::Error for RateLimited {}
const MICRO_PER_TOKEN: u64 = 1_000_000;
#[derive(Debug)]
struct TokenBucket {
ops_per_sec: u64,
burst: u64,
capacity_micros: u64,
state: Mutex<TokenBucketState>,
}
#[derive(Debug)]
struct TokenBucketState {
micro_tokens: u64,
last_refill: Instant,
}
impl TokenBucket {
fn new(ops_per_sec: u64, burst: u64) -> Self {
let burst = burst.max(1);
let ops_per_sec = ops_per_sec.max(1);
let capacity_micros = burst.saturating_mul(MICRO_PER_TOKEN);
Self {
ops_per_sec,
burst,
capacity_micros,
state: Mutex::new(TokenBucketState {
micro_tokens: capacity_micros,
last_refill: Instant::now(),
}),
}
}
fn refilled_micros(&self, current: u64, elapsed: Duration) -> u64 {
let nanos = elapsed.as_nanos();
let credit_micros = nanos
.saturating_mul(self.ops_per_sec as u128)
.saturating_mul(MICRO_PER_TOKEN as u128)
/ 1_000_000_000u128;
let credit_micros = u64::try_from(credit_micros).unwrap_or(u64::MAX);
current
.saturating_add(credit_micros)
.min(self.capacity_micros)
}
fn try_acquire_at(&self, n: u64, now: Instant) -> Result<(), RateLimited> {
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let elapsed = now
.checked_duration_since(state.last_refill)
.unwrap_or(Duration::ZERO);
let available_micros = self.refilled_micros(state.micro_tokens, elapsed);
let need_micros = n.saturating_mul(MICRO_PER_TOKEN);
if available_micros < need_micros {
return Err(RateLimited {
requested: n,
available: available_micros / MICRO_PER_TOKEN,
ops_per_sec: self.ops_per_sec,
burst: self.burst,
});
}
state.micro_tokens = available_micros - need_micros;
state.last_refill = now;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct TenantCapability {
tenant_id: TenantId,
_seal: (),
pub(crate) registry_token: std::sync::Arc<()>,
}
impl TenantCapability {
pub(crate) fn mint(tenant_id: TenantId, registry_token: std::sync::Arc<()>) -> Self {
Self {
tenant_id,
_seal: (),
registry_token,
}
}
pub fn tenant_id(&self) -> TenantId {
self.tenant_id
}
}
#[derive(Debug)]
pub struct TenantContext {
tenant_id: TenantId,
isolation: IsolationKind,
stream_id: u64,
memory_quota_bytes: u64,
bytes_in_use: AtomicU64,
gpu_memory_bytes_cap: Option<u64>,
gpu_bytes_in_use: AtomicU64,
#[allow(dead_code)]
cuda_mem_pool_quota_bytes: Option<u64>,
driver_mem_pool: Option<Arc<dyn DriverMemPool>>,
rate_limiter: Option<TokenBucket>,
#[cfg(feature = "cuda")]
#[allow(dead_code)]
cu_context: Option<cust::context::Context>,
#[cfg(not(feature = "cuda"))]
#[allow(dead_code)]
cu_context: (),
metrics: Option<TensorWasmMetrics>,
metrics_labels: TenantLabels,
pub(crate) registry_token: Option<std::sync::Arc<()>>,
}
impl TenantContext {
pub fn builder(tenant_id: TenantId) -> TenantContextBuilder {
TenantContextBuilder::new(tenant_id)
}
pub fn id(&self) -> TenantId {
self.tenant_id
}
pub fn isolation(&self) -> IsolationKind {
self.isolation
}
pub fn stream_id(&self) -> u64 {
self.stream_id
}
pub fn quota(&self) -> u64 {
self.memory_quota_bytes
}
pub fn bytes_in_use(&self) -> u64 {
self.bytes_in_use.load(Ordering::Acquire)
}
pub fn gpu_memory_bytes_cap(&self) -> Option<u64> {
self.gpu_memory_bytes_cap
}
pub fn gpu_bytes_in_use(&self) -> u64 {
self.gpu_bytes_in_use.load(Ordering::Acquire)
}
pub fn consume_gpu_bytes(&self, n: u64) -> Result<(), TensorWasmError> {
let limit = self.gpu_memory_bytes_cap;
let mut current = self.gpu_bytes_in_use.load(Ordering::Acquire);
loop {
let next = match current.checked_add(n) {
Some(v) => v,
None => {
if limit.is_none() {
tracing::warn!(
target: "tensor_wasm_tenant::context",
tenant = %self.tenant_id,
current,
requested = n,
"consume_gpu_bytes overflow on uncapped tenant; saturating at u64::MAX",
);
u64::MAX
} else {
return Err(TensorWasmError::GpuMemoryExhausted {
requested: n,
limit: limit.unwrap_or(u64::MAX),
current,
});
}
}
};
if let Some(cap) = limit {
if next > cap {
return Err(TensorWasmError::GpuMemoryExhausted {
requested: n,
limit: cap,
current,
});
}
}
match self.gpu_bytes_in_use.compare_exchange_weak(
current,
next,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
self.publish_gpu_memory_gauge(next);
if let (Some(pool), Some(cap)) = (&self.driver_mem_pool, limit) {
if let Err(e) = pool.set_release_threshold(cap) {
tracing::warn!(
target: "tensor_wasm_tenant::context",
tenant = %self.tenant_id,
cap,
error = %e,
"driver mem-pool set_release_threshold failed; \
in-process gpu cap still enforced",
);
}
}
return Ok(());
}
Err(observed) => current = observed,
}
}
}
pub fn release_gpu_bytes(&self, bytes: u64) {
let mut current = self.gpu_bytes_in_use.load(Ordering::Acquire);
let after = loop {
let next = current.saturating_sub(bytes);
match self.gpu_bytes_in_use.compare_exchange_weak(
current,
next,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
if current < bytes {
tracing::warn!(
target: "tensor_wasm_tenant::context",
tenant = %self.tenant_id,
before = current,
bytes,
"release_gpu_bytes underflow clamped",
);
}
break next;
}
Err(observed) => current = observed,
}
};
self.publish_gpu_memory_gauge(after);
}
pub fn consume_gpu_bytes_with_capability(
&self,
cap: &TenantCapability,
n: u64,
) -> Result<(), TensorWasmError> {
self.check_capability(cap, "quota.consume_gpu_bytes")?;
self.consume_gpu_bytes(n)
}
pub fn release_gpu_bytes_with_capability(
&self,
cap: &TenantCapability,
bytes: u64,
) -> Result<(), TensorWasmError> {
self.check_capability(cap, "quota.release_gpu_bytes")?;
self.release_gpu_bytes(bytes);
Ok(())
}
pub fn try_acquire_op(&self) -> Result<(), RateLimited> {
self.try_acquire_ops(1)
}
pub fn try_acquire_ops(&self, n: u64) -> Result<(), RateLimited> {
match &self.rate_limiter {
Some(bucket) => bucket.try_acquire_at(n, Instant::now()),
None => Ok(()),
}
}
pub fn has_rate_limit(&self) -> bool {
self.rate_limiter.is_some()
}
#[deprecated(
since = "0.3.6",
note = "use consume_bytes_with_capability; unchecked variant will be removed in v0.4"
)]
pub fn consume_bytes(&self, n: u64) -> Result<(), TensorWasmError> {
self.consume_bytes_inner(n)
}
pub fn consume_bytes_with_capability(
&self,
cap: &TenantCapability,
n: u64,
) -> Result<(), TensorWasmError> {
self.check_capability(cap, "quota.consume_bytes")?;
self.consume_bytes_inner(n)
}
fn consume_bytes_inner(&self, n: u64) -> Result<(), TensorWasmError> {
let limit = self.memory_quota_bytes;
let mut current = self.bytes_in_use.load(Ordering::Acquire);
loop {
let next = match current.checked_add(n) {
Some(v) if v <= limit => v,
_ => {
return Err(TensorWasmError::MemoryExhausted {
requested: n,
limit,
});
}
};
match self.bytes_in_use.compare_exchange_weak(
current,
next,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
self.publish_memory_gauge(next);
return Ok(());
}
Err(observed) => current = observed,
}
}
}
fn check_capability(
&self,
cap: &TenantCapability,
resource: &'static str,
) -> Result<(), TensorWasmError> {
if cap.tenant_id != self.tenant_id {
return Err(TensorWasmError::TenantIsolationViolation {
tenant_id: cap.tenant_id,
resource: resource.into(),
});
}
match self.registry_token.as_ref() {
Some(ctx_token) if std::sync::Arc::ptr_eq(ctx_token, &cap.registry_token) => {}
_ => {
return Err(TensorWasmError::TenantIsolationViolation {
tenant_id: cap.tenant_id,
resource: resource.into(),
});
}
}
Ok(())
}
fn publish_memory_gauge(&self, new_total: u64) {
if let Some(metrics) = &self.metrics {
metrics
.cpu_memory_bytes_per_tenant()
.get_or_create(&self.metrics_labels)
.set(new_total);
}
}
fn publish_gpu_memory_gauge(&self, new_total: u64) {
if let Some(metrics) = &self.metrics {
metrics
.gpu_memory_bytes_per_tenant()
.get_or_create(&self.metrics_labels)
.set(new_total);
}
}
pub fn cuda_mem_pool_quota_bytes(&self) -> Option<u64> {
self.cuda_mem_pool_quota_bytes
}
pub fn mem_pool(&self) -> Option<&Arc<dyn DriverMemPool>> {
self.driver_mem_pool.as_ref()
}
#[cfg(feature = "cuda")]
pub fn enter(&self) -> Option<CudaCtxGuard<'_>> {
let ctx = self.cu_context.as_ref()?;
CudaCtxGuard::push(ctx)
.ok()
.map(|g| g.with_tenant(self.tenant_id))
}
#[cfg(not(feature = "cuda"))]
pub fn enter(&self) -> Option<CudaCtxGuard> {
None
}
#[deprecated(
since = "0.3.6",
note = "use release_bytes_with_capability; unchecked variant will be removed in v0.4"
)]
pub fn release_bytes(&self, bytes: u64) {
self.release_bytes_inner(bytes);
}
pub fn release_bytes_with_capability(
&self,
cap: &TenantCapability,
bytes: u64,
) -> Result<(), TensorWasmError> {
self.check_capability(cap, "quota.release_bytes")?;
self.release_bytes_inner(bytes);
Ok(())
}
fn release_bytes_inner(&self, bytes: u64) {
let mut current = self.bytes_in_use.load(Ordering::Acquire);
let after = loop {
let next = current.saturating_sub(bytes);
match self.bytes_in_use.compare_exchange_weak(
current,
next,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
if current < bytes {
tracing::warn!(
target: "tensor_wasm_tenant::context",
tenant = %self.tenant_id,
before = current,
bytes,
"release_bytes underflow clamped",
);
}
break next;
}
Err(observed) => current = observed,
}
};
self.publish_memory_gauge(after);
}
pub fn has_real_context(&self) -> bool {
#[cfg(feature = "cuda")]
{
self.cu_context.is_some()
}
#[cfg(not(feature = "cuda"))]
{
false
}
}
}
#[cfg(feature = "cuda")]
pub struct CudaCtxGuard<'a> {
_ctx: std::marker::PhantomData<&'a cust::context::Context>,
tenant_id: Option<TenantId>,
}
#[cfg(feature = "cuda")]
impl<'a> CudaCtxGuard<'a> {
pub fn push(ctx: &'a cust::context::Context) -> Result<Self, cust::error::CudaError> {
cust::context::legacy::ContextStack::push(ctx)?;
Ok(Self {
_ctx: std::marker::PhantomData,
tenant_id: None,
})
}
}
#[cfg(feature = "cuda")]
impl<'a> CudaCtxGuard<'a> {
fn with_tenant(self, tenant_id: TenantId) -> Self {
Self {
tenant_id: Some(tenant_id),
..self
}
}
}
#[cfg(feature = "cuda")]
impl Drop for CudaCtxGuard<'_> {
fn drop(&mut self) {
if let Err(e) = cust::context::legacy::ContextStack::pop() {
tracing::error!(
target: "tensor_wasm_tenant::context",
error = ?e,
tenant = ?self.tenant_id,
"cuCtxPopCurrent failed in CudaCtxGuard::drop",
);
}
}
}
#[cfg(not(feature = "cuda"))]
pub struct CudaCtxGuard;
#[derive(Debug)]
pub struct TenantContextBuilder {
tenant_id: TenantId,
isolation: IsolationKind,
stream_id: u64,
memory_quota_bytes: u64,
cuda_mem_pool_quota_bytes: Option<u64>,
gpu_memory_bytes_cap: Option<u64>,
driver_mem_pool: Option<Arc<dyn DriverMemPool>>,
rate_limit: Option<(u64, u64)>,
#[cfg(feature = "cuda")]
cuda_device_index: Option<u32>,
metrics: Option<TensorWasmMetrics>,
}
impl TenantContextBuilder {
pub const DEFAULT_QUOTA_BYTES: u64 = 8 * 1024 * 1024 * 1024;
pub fn new(tenant_id: TenantId) -> Self {
Self {
tenant_id,
isolation: IsolationKind::default(),
stream_id: 0,
memory_quota_bytes: Self::DEFAULT_QUOTA_BYTES,
cuda_mem_pool_quota_bytes: None,
gpu_memory_bytes_cap: None,
driver_mem_pool: None,
rate_limit: None,
#[cfg(feature = "cuda")]
cuda_device_index: None,
metrics: None,
}
}
pub fn with_gpu_memory_bytes_cap(mut self, bytes: u64) -> Self {
self.gpu_memory_bytes_cap = Some(bytes);
self
}
pub fn with_metrics(mut self, metrics: TensorWasmMetrics) -> Self {
self.metrics = Some(metrics);
self
}
pub fn with_isolation(mut self, isolation: IsolationKind) -> Self {
self.isolation = isolation;
self
}
pub fn with_stream_id(mut self, stream_id: u64) -> Self {
self.stream_id = stream_id;
self
}
pub fn with_memory_quota_bytes(mut self, memory_quota_bytes: u64) -> Self {
self.memory_quota_bytes = memory_quota_bytes;
self
}
pub fn with_recorded_cuda_mem_pool_quota(mut self, bytes: u64) -> Self {
self.cuda_mem_pool_quota_bytes = Some(bytes);
self
}
pub fn with_driver_enforced_gpu_cap(mut self, pool: Arc<dyn DriverMemPool>) -> Self {
self.driver_mem_pool = Some(pool);
self
}
pub fn with_rate_limit(mut self, ops_per_sec: u64, burst: u64) -> Self {
self.rate_limit = Some((ops_per_sec, burst));
self
}
#[cfg(feature = "cuda")]
pub fn with_cuda_device_index(mut self, device_index: u32) -> Self {
self.cuda_device_index = Some(device_index);
self
}
#[allow(unused_mut)]
pub fn build(mut self) -> TenantContext {
#[cfg(feature = "cuda")]
let cu_context = {
let want_isolated = matches!(self.isolation, IsolationKind::ContextIsolated);
let built = self.build_cuda_context();
if want_isolated && built.is_none() {
ISOLATION_DOWNGRADE_COUNT.fetch_add(1, Ordering::Relaxed);
tracing::error!(
target: "tensor_wasm_tenant::context",
tenant = %self.tenant_id,
requested = %IsolationKind::ContextIsolated,
effective = %IsolationKind::StreamIsolated,
"ContextIsolated requested but unavailable; downgraded to StreamIsolated",
);
self.isolation = IsolationKind::StreamIsolated;
}
built
};
#[cfg(not(feature = "cuda"))]
let cu_context = ();
let metrics_labels = TenantLabels::new(self.tenant_id.to_string());
TenantContext {
tenant_id: self.tenant_id,
isolation: self.isolation,
stream_id: self.stream_id,
memory_quota_bytes: self.memory_quota_bytes,
bytes_in_use: AtomicU64::new(0),
gpu_memory_bytes_cap: self.gpu_memory_bytes_cap,
gpu_bytes_in_use: AtomicU64::new(0),
cuda_mem_pool_quota_bytes: self.cuda_mem_pool_quota_bytes,
driver_mem_pool: self.driver_mem_pool,
rate_limiter: self
.rate_limit
.map(|(ops_per_sec, burst)| TokenBucket::new(ops_per_sec, burst)),
cu_context,
metrics: self.metrics,
metrics_labels,
registry_token: None,
}
}
#[cfg(feature = "cuda")]
fn build_cuda_context(&self) -> Option<cust::context::Context> {
if !matches!(self.isolation, IsolationKind::ContextIsolated) {
return None;
}
let device_idx = self.cuda_device_index.unwrap_or(0);
let device = match cust::device::Device::get_device(device_idx as i32) {
Ok(d) => d,
Err(e) => {
tracing::error!(
target: "tensor_wasm_tenant::context",
tenant = %self.tenant_id,
device = device_idx,
error = ?e,
"Device::get_device failed; falling back to stream-isolated mode",
);
return None;
}
};
match cust::context::Context::new(device) {
Ok(ctx) => Some(ctx),
Err(e) => {
tracing::error!(
target: "tensor_wasm_tenant::context",
tenant = %self.tenant_id,
device = device_idx,
error = ?e,
"Context::new failed; falling back to stream-isolated mode",
);
None
}
}
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
use tensor_wasm_core::mem_pool::MemPoolError;
#[test]
fn builder_defaults() {
let ctx = TenantContext::builder(TenantId(1)).build();
assert_eq!(ctx.id(), TenantId(1));
assert_eq!(ctx.isolation(), IsolationKind::StreamIsolated);
assert_eq!(ctx.stream_id(), 0);
assert_eq!(ctx.quota(), TenantContextBuilder::DEFAULT_QUOTA_BYTES);
assert_eq!(ctx.bytes_in_use(), 0);
}
#[test]
fn builder_overrides() {
let ctx = TenantContext::builder(TenantId(7))
.with_isolation(IsolationKind::ContextIsolated)
.with_stream_id(42)
.with_memory_quota_bytes(1024)
.build();
assert_eq!(ctx.isolation(), IsolationKind::ContextIsolated);
assert_eq!(ctx.stream_id(), 42);
assert_eq!(ctx.quota(), 1024);
}
#[test]
fn quota_consume_release_round_trip() {
let ctx = TenantContext::builder(TenantId(2))
.with_memory_quota_bytes(1024)
.build();
ctx.consume_bytes(256).unwrap();
assert_eq!(ctx.bytes_in_use(), 256);
ctx.consume_bytes(512).unwrap();
assert_eq!(ctx.bytes_in_use(), 768);
ctx.release_bytes(256);
assert_eq!(ctx.bytes_in_use(), 512);
}
#[test]
fn quota_enforcement_rejects_over_limit() {
let ctx = TenantContext::builder(TenantId(3))
.with_memory_quota_bytes(1024)
.build();
ctx.consume_bytes(1000).unwrap();
let err = ctx.consume_bytes(100).unwrap_err();
match err {
TensorWasmError::MemoryExhausted { requested, limit } => {
assert_eq!(requested, 100);
assert_eq!(limit, 1024);
}
other => panic!("expected MemoryExhausted, got {other:?}"),
}
assert_eq!(ctx.bytes_in_use(), 1000);
}
#[test]
fn release_saturates_on_underflow() {
let ctx = TenantContext::builder(TenantId(4))
.with_memory_quota_bytes(1024)
.build();
ctx.release_bytes(999); assert_eq!(ctx.bytes_in_use(), 0);
}
#[test]
fn isolation_kind_names_are_stable() {
assert_eq!(IsolationKind::Shared.name(), "shared");
assert_eq!(IsolationKind::StreamIsolated.name(), "stream_isolated");
assert_eq!(IsolationKind::ContextIsolated.name(), "context_isolated");
}
#[test]
fn isolation_kind_matches_each_variant() {
for kind in [
IsolationKind::Shared,
IsolationKind::StreamIsolated,
IsolationKind::ContextIsolated,
] {
let ctx = TenantContext::builder(TenantId(99))
.with_isolation(kind)
.build();
assert_eq!(ctx.isolation(), kind);
assert_eq!(ctx.isolation().to_string(), kind.name());
}
}
#[test]
fn isolation_kind_default_is_stream_isolated() {
assert_eq!(IsolationKind::default(), IsolationKind::StreamIsolated);
}
#[test]
fn cuda_mem_pool_quota_default_is_none() {
let ctx = TenantContext::builder(TenantId(5)).build();
assert_eq!(ctx.cuda_mem_pool_quota_bytes(), None);
}
#[test]
fn cuda_mem_pool_quota_recorded() {
let ctx = TenantContext::builder(TenantId(6))
.with_recorded_cuda_mem_pool_quota(4 * 1024 * 1024 * 1024)
.build();
assert_eq!(
ctx.cuda_mem_pool_quota_bytes(),
Some(4 * 1024 * 1024 * 1024)
);
}
#[test]
fn metrics_handle_absent_by_default_is_a_noop() {
let ctx = TenantContext::builder(TenantId(11))
.with_memory_quota_bytes(8192)
.build();
ctx.consume_bytes(1024).unwrap();
ctx.release_bytes(512);
assert_eq!(ctx.bytes_in_use(), 512);
}
#[test]
fn metrics_handle_publishes_consume_and_release_totals() {
let metrics = TensorWasmMetrics::new();
let ctx = TenantContext::builder(TenantId(12))
.with_memory_quota_bytes(1 << 20)
.with_metrics(metrics.clone())
.build();
let labels = TenantLabels::new(TenantId(12).to_string());
let cpu = || {
metrics
.cpu_memory_bytes_per_tenant()
.get_or_create(&labels)
.get()
};
ctx.consume_bytes(4096).unwrap();
assert_eq!(cpu(), 4096);
ctx.consume_bytes(2048).unwrap();
assert_eq!(cpu(), 6144);
ctx.release_bytes(2048);
assert_eq!(cpu(), 4096);
}
#[test]
fn gpu_metrics_publish_to_per_tenant_family() {
let metrics = TensorWasmMetrics::new();
let ctx = TenantContext::builder(TenantId(12))
.with_metrics(metrics.clone())
.build();
let labels = TenantLabels::new(TenantId(12).to_string());
ctx.consume_gpu_bytes(4096).unwrap();
assert_eq!(
metrics
.gpu_memory_bytes_per_tenant()
.get_or_create(&labels)
.get(),
4096
);
ctx.release_gpu_bytes(2048);
assert_eq!(
metrics
.gpu_memory_bytes_per_tenant()
.get_or_create(&labels)
.get(),
2048
);
ctx.consume_bytes(1024).unwrap();
assert_eq!(
metrics
.gpu_memory_bytes_per_tenant()
.get_or_create(&labels)
.get(),
2048
);
}
#[derive(Debug, Default)]
struct MockDriverMemPool {
threshold: AtomicU64,
set_calls: AtomicU64,
fail: bool,
}
impl DriverMemPool for MockDriverMemPool {
fn set_release_threshold(&self, bytes: u64) -> Result<(), MemPoolError> {
self.set_calls.fetch_add(1, Ordering::SeqCst);
if self.fail {
return Err(MemPoolError::SetAttribute("mock forced failure".into()));
}
self.threshold.store(bytes, Ordering::SeqCst);
Ok(())
}
fn release_threshold(&self) -> Option<u64> {
Some(self.threshold.load(Ordering::SeqCst))
}
}
#[test]
fn driver_enforced_gpu_cap_pushes_threshold_on_consume() {
let pool = Arc::new(MockDriverMemPool::default());
let ctx = TenantContext::builder(TenantId(20))
.with_gpu_memory_bytes_cap(4096)
.with_driver_enforced_gpu_cap(pool.clone())
.build();
assert!(ctx.mem_pool().is_some());
ctx.consume_gpu_bytes(1000).unwrap();
assert_eq!(pool.set_calls.load(Ordering::SeqCst), 1);
assert_eq!(pool.threshold.load(Ordering::SeqCst), 4096);
assert_eq!(pool.release_threshold(), Some(4096));
ctx.consume_gpu_bytes(500).unwrap();
assert_eq!(pool.set_calls.load(Ordering::SeqCst), 2);
assert_eq!(pool.threshold.load(Ordering::SeqCst), 4096);
}
#[test]
fn driver_enforced_gpu_cap_not_pushed_without_cap() {
let pool = Arc::new(MockDriverMemPool::default());
let ctx = TenantContext::builder(TenantId(21))
.with_driver_enforced_gpu_cap(pool.clone())
.build();
ctx.consume_gpu_bytes(1000).unwrap();
assert_eq!(pool.set_calls.load(Ordering::SeqCst), 0);
assert!(ctx.mem_pool().is_some());
}
#[test]
fn driver_enforced_gpu_cap_over_limit_does_not_push() {
let pool = Arc::new(MockDriverMemPool::default());
let ctx = TenantContext::builder(TenantId(22))
.with_gpu_memory_bytes_cap(1024)
.with_driver_enforced_gpu_cap(pool.clone())
.build();
let err = ctx.consume_gpu_bytes(2048).unwrap_err();
assert!(matches!(err, TensorWasmError::GpuMemoryExhausted { .. }));
assert_eq!(pool.set_calls.load(Ordering::SeqCst), 0);
assert_eq!(ctx.gpu_bytes_in_use(), 0);
}
#[test]
fn driver_enforced_gpu_cap_set_failure_is_fail_soft() {
let pool = Arc::new(MockDriverMemPool {
fail: true,
..Default::default()
});
let ctx = TenantContext::builder(TenantId(23))
.with_gpu_memory_bytes_cap(4096)
.with_driver_enforced_gpu_cap(pool.clone())
.build();
ctx.consume_gpu_bytes(1000).unwrap();
assert_eq!(pool.set_calls.load(Ordering::SeqCst), 1);
assert_eq!(pool.threshold.load(Ordering::SeqCst), 0);
assert_eq!(ctx.gpu_bytes_in_use(), 1000);
}
#[test]
fn mem_pool_accessor_default_is_none() {
let ctx = TenantContext::builder(TenantId(24)).build();
assert!(ctx.mem_pool().is_none());
}
#[test]
fn metrics_two_tenants_produce_two_distinct_series() {
let metrics = TensorWasmMetrics::new();
let a = TenantContext::builder(TenantId(101))
.with_metrics(metrics.clone())
.build();
let b = TenantContext::builder(TenantId(102))
.with_metrics(metrics.clone())
.build();
a.consume_gpu_bytes(4096).unwrap();
b.consume_gpu_bytes(8192).unwrap();
let text = metrics.encode_text();
assert!(
text.contains("tensor_wasm_gpu_memory_bytes_per_tenant{tenant_id=\"T#101\"} 4096"),
"missing tenant 101 sample in:\n{text}"
);
assert!(
text.contains("tensor_wasm_gpu_memory_bytes_per_tenant{tenant_id=\"T#102\"} 8192"),
"missing tenant 102 sample in:\n{text}"
);
}
#[test]
fn metrics_release_underflow_publishes_clamped_zero() {
let metrics = TensorWasmMetrics::new();
let ctx = TenantContext::builder(TenantId(13))
.with_memory_quota_bytes(1 << 16)
.with_metrics(metrics.clone())
.build();
let labels = TenantLabels::new(TenantId(13).to_string());
ctx.release_bytes(123);
assert_eq!(
metrics
.cpu_memory_bytes_per_tenant()
.get_or_create(&labels)
.get(),
0
);
}
#[test]
fn enter_returns_none_without_cuda_context() {
let ctx = TenantContext::builder(TenantId(8)).build();
assert!(ctx.enter().is_none());
}
#[test]
fn release_underflow_does_not_overwrite_concurrent_consume() {
use std::thread;
const ITERATIONS: u64 = 10_000;
const BYTES: u64 = 100;
const PRE_LOAD: u64 = BYTES - 1; const CONSUME: u64 = 7;
let ctx = Arc::new(
TenantContext::builder(TenantId(0xAFE))
.with_memory_quota_bytes(u64::MAX)
.build(),
);
ctx.consume_bytes(PRE_LOAD).unwrap();
let releaser = {
let ctx = Arc::clone(&ctx);
thread::spawn(move || {
for _ in 0..ITERATIONS {
ctx.release_bytes(BYTES);
}
})
};
let consumer = {
let ctx = Arc::clone(&ctx);
thread::spawn(move || {
for _ in 0..ITERATIONS {
ctx.consume_bytes(CONSUME).unwrap();
}
})
};
releaser.join().expect("releaser thread panicked");
consumer.join().expect("consumer thread panicked");
let final_value = ctx.bytes_in_use();
let upper = PRE_LOAD.saturating_add(ITERATIONS.saturating_mul(CONSUME));
assert!(
final_value <= upper,
"final {final_value} exceeded upper bound {upper}"
);
assert!(
final_value < u64::MAX / 2,
"final {final_value} suggests wrap-around — the race was not fixed",
);
}
#[test]
fn rate_limit_absent_by_default_always_admits() {
let ctx = TenantContext::builder(TenantId(30)).build();
assert!(!ctx.has_rate_limit());
for _ in 0..10_000 {
ctx.try_acquire_op().expect("no limiter must always admit");
}
}
#[test]
fn token_bucket_admits_up_to_burst_then_rejects() {
let bucket = TokenBucket::new( 100, 5);
let t0 = Instant::now();
for i in 0..5 {
bucket
.try_acquire_at(1, t0)
.unwrap_or_else(|_| panic!("op {i} within burst must be admitted"));
}
let err = bucket
.try_acquire_at(1, t0)
.expect_err("6th op past burst must be rejected");
assert_eq!(err.requested, 1);
assert_eq!(err.available, 0);
assert_eq!(err.ops_per_sec, 100);
assert_eq!(err.burst, 5);
}
#[test]
fn token_bucket_refills_over_time() {
let bucket = TokenBucket::new( 10, 2);
let t0 = Instant::now();
bucket.try_acquire_at(1, t0).unwrap();
bucket.try_acquire_at(1, t0).unwrap();
assert!(bucket.try_acquire_at(1, t0).is_err(), "bucket drained");
let t1 = t0 + Duration::from_millis(100);
bucket
.try_acquire_at(1, t1)
.expect("one token should have refilled after 100ms");
assert!(
bucket.try_acquire_at(1, t1).is_err(),
"only one token refilled; second must be rejected"
);
let t2 = t0 + Duration::from_secs(1);
bucket.try_acquire_at(1, t2).unwrap();
bucket.try_acquire_at(1, t2).unwrap();
assert!(
bucket.try_acquire_at(1, t2).is_err(),
"refill is capped at burst depth"
);
}
#[test]
fn token_bucket_acquire_n_is_all_or_nothing() {
let bucket = TokenBucket::new(100, 5);
let t0 = Instant::now();
let err = bucket
.try_acquire_at(8, t0)
.expect_err("8 > burst 5 must reject");
assert_eq!(err.requested, 8);
assert_eq!(err.available, 5);
bucket
.try_acquire_at(5, t0)
.expect("full burst still available after a rejected over-budget request");
}
#[test]
fn try_acquire_ops_via_builder_uses_bytes_per_sec_budget() {
let ctx = TenantContext::builder(TenantId(31))
.with_rate_limit( 1_000, 1_000)
.build();
assert!(ctx.has_rate_limit());
ctx.try_acquire_ops(600).unwrap();
ctx.try_acquire_ops(400).unwrap();
let err = ctx
.try_acquire_ops(1)
.expect_err("bucket drained to zero bytes");
assert_eq!(err.burst, 1_000);
}
#[test]
fn rate_limit_zero_args_clamped_to_one() {
let bucket = TokenBucket::new(0, 0);
let t0 = Instant::now();
bucket
.try_acquire_at(1, t0)
.expect("clamped burst of 1 admits one");
assert!(bucket.try_acquire_at(1, t0).is_err());
}
#[test]
fn isolation_downgrade_counter_starts_at_zero() {
let count = isolation_downgrade_count();
assert_eq!(
count,
isolation_downgrade_count(),
"isolation_downgrade_count() must be a side-effect-free read",
);
#[cfg(not(feature = "cuda"))]
{
assert_eq!(
count, 0,
"isolation_downgrade_count should start at zero on no-CUDA builds; \
a non-zero reading means a downgrade was attributed to the wrong \
path or a prior test mutated the static",
);
}
}
}