# Task 02: TenantRouter ## Delivers `TenantRouter` in `tidal/src/replication/tenant.rs` (same file as `TenantId`/`TenantConfig`). Extends `ShardRouter` with tenant-aware routing: `(TenantId, EntityId) -> (RegionId, ShardId)`. Default routing uses consistent hashing. Residency policy constrains which regions are eligible for a tenant's data. ## Complexity: M ## Dependencies - Task 01 (TenantId, TenantConfig) - Phase 8.1, Task 02 (ShardRouter) ## Technical Design ```rust // tidal/src/replication/tenant.rs (continued) /// Maps (TenantId, EntityId) -> (RegionId, ShardId) for data placement. /// /// Wraps `ShardRouter` and adds: /// 1. Tenant-to-shard affinity (consistent hash or explicit assignment) /// 2. Residency policy enforcement (required_regions constraint) /// 3. Tenant registry for O(1) config lookup pub struct TenantRouter { /// Inner shard router (entity-level routing). shard_router: Arc, /// Per-tenant configuration. tenants: DashMap, /// Cluster topology: which shards are in which regions. topology: Arc, } /// Cluster topology snapshot: maps ShardId -> RegionId. #[derive(Debug, Clone)] pub struct ClusterTopology { /// Ordered list of (ShardId, RegionId) assignments. shards: Vec, } #[derive(Debug, Clone, Copy)] pub struct ShardAssignment { pub shard_id: ShardId, pub region_id: RegionId, } impl TenantRouter { pub fn new(shard_router: Arc, topology: Arc) -> Self { Self { shard_router, tenants: DashMap::new(), topology, } } /// Register or update a tenant's configuration. pub fn register_tenant(&self, config: TenantConfig) { self.tenants.insert(config.tenant_id, config); } /// Look up routing for a (TenantId, EntityId) pair. /// /// Returns `(RegionId, ShardId)` for data placement. /// Applies residency policy if configured. pub fn route( &self, tenant_id: TenantId, entity_id: EntityId, ) -> Result { // 1. Get eligible shards (all shards if no policy; filtered by region if policy set). let eligible_shards = self.eligible_shards_for(tenant_id)?; // 2. Consistent hash over eligible shards. let shard = self.consistent_hash(entity_id, &eligible_shards); Ok(shard) } /// Returns the primary shard assignment for a tenant's data. /// /// For single-shard tenants: always the same shard. /// For multi-shard tenants: hash-distributed. fn eligible_shards_for(&self, tenant_id: TenantId) -> Result> { let config = self.tenants.get(&tenant_id); if let Some(config) = config { if !config.required_regions.is_empty() { // Filter topology to only shards in required regions. let eligible: Vec<_> = self.topology.shards.iter() .copied() .filter(|s| config.required_regions.contains(&s.region_id)) .collect(); if eligible.is_empty() { return Err(TidalError::Configuration( format!("tenant {:?} residency policy has no eligible shards", tenant_id) )); } return Ok(eligible); } } // No residency constraint: all shards eligible. Ok(self.topology.shards.clone()) } /// Consistent hash: jumps hash over the eligible shard list. /// /// Uses Jump Consistent Hash (Lamping & Veach, 2014) for minimal /// remapping when shards are added/removed. fn consistent_hash(&self, entity_id: EntityId, shards: &[ShardAssignment]) -> ShardAssignment { let n = shards.len() as u64; let slot = jump_hash(entity_id.0, n); shards[slot as usize] } /// Rate limiter for a tenant (lazily created). pub fn rate_limiter_for(&self, tenant_id: TenantId) -> Option> { self.tenants.get(&tenant_id) .and_then(|c| c.max_signals_per_sec) .map(|rate| Arc::new(TenantRateLimiter::new(rate))) } } /// Jump Consistent Hash (O(ln n) time, O(1) space). fn jump_hash(key: u64, num_buckets: u64) -> u64 { let mut k = key; let mut b: i64 = -1; let mut j: i64 = 0; while j < num_buckets as i64 { b = j; k = k.wrapping_mul(2862933555777941757).wrapping_add(1); j = ((b + 1) as f64 * (((1u64 << 31) as f64) / (((k >> 33) + 1) as f64))) as i64; } b as u64 } ``` ### Integration with TidalDb Write Path ```rust // tidal/src/db/mod.rs (additions to signal write path) impl TidalDb { pub fn signal_for_tenant( &self, tenant_id: TenantId, signal_type: &str, entity_id: EntityId, weight: f64, timestamp: Timestamp, ) -> crate::Result<()> { // 1. Check rate limit. if let Some(limiter) = self.tenant_router.rate_limiter_for(tenant_id) { limiter.try_acquire()?; } // 2. Route to shard. let assignment = self.tenant_router.route(tenant_id, entity_id)?; // 3. Write signal to the tenant-scoped signal ledger. self.signal_impl(signal_type, entity_id, weight, timestamp) } } ``` ## Acceptance Criteria - [ ] `TenantRouter::route(tenant_id, entity_id)` returns a `ShardAssignment` from the eligible shards - [ ] Residency policy: if `TenantConfig::required_regions = [RegionId(1)]` and only shard 2 is in region 1, all entities for that tenant route to shard 2 - [ ] Residency policy violation: if required regions have no shards in `ClusterTopology`, returns `TidalError::Configuration` - [ ] Consistent hash is stable: same `(tenant_id, entity_id)` always maps to the same shard unless topology changes - [ ] Jump hash: adding a shard remaps approximately `1/N` of keys (property test: 10K keys, add 1 shard, verify < 15% remapping) - [ ] `TidalDb::signal_for_tenant` applies rate limiting before write; `QuotaExceeded` is returned before WAL write (no partial state) - [ ] `cargo clippy -D warnings` and `cargo fmt` pass