@@ -26,7 +26,7 @@ use std::{
2626 atomic:: { AtomicU64 , AtomicUsize , Ordering } ,
2727 } ,
2828} ;
29- use tokio:: sync:: { Mutex as AsyncMutex , RwLock as AsyncRwLock , mpsc} ;
29+ use tokio:: sync:: { Mutex as AsyncMutex , OwnedRwLockReadGuard , RwLock as AsyncRwLock , mpsc} ;
3030
3131use crate :: {
3232 catalog:: manifest:: { File , Manifest } ,
@@ -286,6 +286,7 @@ impl QueryPin {
286286pub struct HotTierQueryGuard {
287287 state : Arc < StreamSyncState > ,
288288 pin_id : u64 ,
289+ _tenant_admission : Option < OwnedRwLockReadGuard < bool > > ,
289290}
290291
291292impl Drop for HotTierQueryGuard {
@@ -316,6 +317,7 @@ impl StreamSyncState {
316317 HotTierQueryGuard {
317318 state : self . clone ( ) ,
318319 pin_id,
320+ _tenant_admission : None ,
319321 }
320322 }
321323}
@@ -431,6 +433,7 @@ struct StreamTasks {
431433pub struct HotTierManager {
432434 filesystem : LocalFileSystem ,
433435 hot_tier_path : & ' static Path ,
436+ tenant_gates : AsyncMutex < HashMap < String , Arc < AsyncRwLock < bool > > > > ,
434437 state_cache : AsyncRwLock < HashMap < StreamKey , Arc < StreamSyncState > > > ,
435438 state_init_locks : AsyncMutex < HashMap < StreamKey , Arc < AsyncMutex < ( ) > > > > ,
436439 tasks : AsyncRwLock < HashMap < StreamKey , StreamTasks > > ,
@@ -544,13 +547,37 @@ impl HotTierManager {
544547 HotTierManager {
545548 filesystem : LocalFileSystem :: new ( ) ,
546549 hot_tier_path,
550+ tenant_gates : AsyncMutex :: new ( HashMap :: new ( ) ) ,
547551 state_cache : AsyncRwLock :: new ( HashMap :: new ( ) ) ,
548552 state_init_locks : AsyncMutex :: new ( HashMap :: new ( ) ) ,
549553 tasks : AsyncRwLock :: new ( HashMap :: new ( ) ) ,
550554 sender,
551555 }
552556 }
553557
558+ async fn tenant_gate ( & self , tenant_id : & str ) -> Arc < AsyncRwLock < bool > > {
559+ self . tenant_gates
560+ . lock ( )
561+ . await
562+ . entry ( tenant_id. to_owned ( ) )
563+ . or_insert_with ( || Arc :: new ( AsyncRwLock :: new ( false ) ) )
564+ . clone ( )
565+ }
566+
567+ async fn tenant_admission (
568+ & self ,
569+ tenant_id : & Option < String > ,
570+ ) -> Result < Option < OwnedRwLockReadGuard < bool > > , ( ) > {
571+ let Some ( tenant_id) = tenant_id else {
572+ return Ok ( None ) ;
573+ } ;
574+ let admission = self . tenant_gate ( tenant_id) . await . read_owned ( ) . await ;
575+ if * admission || !TENANT_METADATA . contains_tenant ( tenant_id) {
576+ return Err ( ( ) ) ;
577+ }
578+ Ok ( Some ( admission) )
579+ }
580+
554581 #[ tracing:: instrument( name = "hottier.startup" , skip( self ) ) ]
555582 pub async fn start_all_tasks ( & ' static self ) {
556583 let _ = tokio:: spawn ( async move {
@@ -574,6 +601,22 @@ impl HotTierManager {
574601 & self ,
575602 stream : & str ,
576603 tenant_id : & Option < String > ,
604+ ) -> Result < Arc < StreamSyncState > , HotTierError > {
605+ let admission = self
606+ . tenant_admission ( tenant_id)
607+ . await
608+ . map_err ( |( ) | HotTierValidationError :: NotFound ( stream. to_owned ( ) ) ) ?;
609+ let state = self
610+ . get_or_load_state_with_admission ( stream, tenant_id)
611+ . await ?;
612+ drop ( admission) ;
613+ Ok ( state)
614+ }
615+
616+ async fn get_or_load_state_with_admission (
617+ & self ,
618+ stream : & str ,
619+ tenant_id : & Option < String > ,
577620 ) -> Result < Arc < StreamSyncState > , HotTierError > {
578621 let key: StreamKey = ( tenant_id. clone ( ) , stream. to_owned ( ) ) ;
579622 {
@@ -629,9 +672,16 @@ impl HotTierManager {
629672 start : DateTime < Utc > ,
630673 end : DateTime < Utc > ,
631674 ) -> Result < HotTierQueryGuard , HotTierError > {
632- let state = self . get_or_load_state ( stream, tenant_id) . await ?;
675+ let admission = self
676+ . tenant_admission ( tenant_id)
677+ . await
678+ . map_err ( |( ) | HotTierValidationError :: NotFound ( stream. to_owned ( ) ) ) ?;
679+ let state = self
680+ . get_or_load_state_with_admission ( stream, tenant_id)
681+ . await ?;
633682 let registration_guard = state. eviction . read ( ) . await ;
634- let query_guard = state. pin_query ( start, end) ;
683+ let mut query_guard = state. pin_query ( start, end) ;
684+ query_guard. _tenant_admission = admission;
635685 drop ( registration_guard) ;
636686 Ok ( query_guard)
637687 }
@@ -644,6 +694,39 @@ impl HotTierManager {
644694 }
645695 }
646696
697+ /// Stop all hot-tier work and discard cached state for a deleted tenant.
698+ pub async fn cleanup_tenant ( & self , tenant_id : & str ) {
699+ let tenant = Some ( tenant_id. to_owned ( ) ) ;
700+ let gate = self . tenant_gate ( tenant_id) . await ;
701+ * gate. write ( ) . await = true ;
702+ let removed_tasks = {
703+ let mut tasks = self . tasks . write ( ) . await ;
704+ tasks
705+ . extract_if ( |( task_tenant, _) , _| task_tenant == & tenant)
706+ . map ( |( _, task) | task)
707+ . collect :: < Vec < _ > > ( )
708+ } ;
709+
710+ for task in removed_tasks {
711+ task. latest . abort ( ) ;
712+ let _ = task. latest . await ;
713+ }
714+
715+ self . state_cache
716+ . write ( )
717+ . await
718+ . retain ( |( state_tenant, _) , _| state_tenant != & tenant) ;
719+ self . state_init_locks
720+ . lock ( )
721+ . await
722+ . retain ( |( state_tenant, _) , _| state_tenant != & tenant) ;
723+ }
724+
725+ /// Remove the closed admission gate after tenant metadata is gone.
726+ pub async fn finish_tenant_cleanup ( & self , tenant_id : & str ) {
727+ self . tenant_gates . lock ( ) . await . remove ( tenant_id) ;
728+ }
729+
647730 /// get the total hot tier size for all streams
648731 #[ tracing:: instrument(
649732 name = "hottier.get_hot_tiers_size" ,
@@ -880,18 +963,21 @@ impl HotTierManager {
880963 fields( stream = %stream, tenant = ?tenant_id)
881964 ) ]
882965 pub async fn spawn_stream_task ( & ' static self , stream : String , tenant_id : Option < String > ) {
883- let _ = tokio :: spawn ( async move {
884- self . sender
885- . send ( HotTierMessage :: StartTask ( ( tenant_id , stream ) ) )
886- . unwrap ( ) ;
887- } )
888- . instrument ( tracing :: Span :: current ( ) )
889- . await ;
966+ let Ok ( admission ) = self . tenant_admission ( & tenant_id ) . await else {
967+ return ;
968+ } ;
969+ self . sender
970+ . send ( HotTierMessage :: StartTask ( ( tenant_id , stream ) ) )
971+ . unwrap ( ) ;
972+ drop ( admission ) ;
890973 }
891974
892975 /// Spawn Latest loop for a single stream. Idempotent:
893976 /// if tasks already exist for this (tenant, stream), no-op.
894977 async fn spawn_stream_task_inner ( & ' static self , stream : String , tenant_id : Option < String > ) {
978+ let Ok ( admission) = self . tenant_admission ( & tenant_id) . await else {
979+ return ;
980+ } ;
895981 let key: StreamKey = ( tenant_id. clone ( ) , stream. clone ( ) ) ;
896982
897983 let mut tasks = self . tasks . write ( ) . await ;
@@ -912,6 +998,12 @@ impl HotTierManager {
912998 interval. set_missed_tick_behavior ( tokio:: time:: MissedTickBehavior :: Skip ) ;
913999 loop {
9141000 interval. tick ( ) . await ;
1001+ if t. as_deref ( )
1002+ . is_some_and ( |tenant| !TENANT_METADATA . contains_tenant ( tenant) )
1003+ {
1004+ info ! ( stream = %s, tenant = ?t, "stopping hot tier task for deleted tenant" ) ;
1005+ break ;
1006+ }
9151007 let anchor = floor_to_minute ( Utc :: now ( ) ) ;
9161008 let tick_span = tracing:: info_span!(
9171009 "hottier.tick" ,
@@ -932,6 +1024,8 @@ impl HotTierManager {
9321024 if let Some ( old) = tasks. insert ( key, StreamTasks { latest } ) {
9331025 old. latest . abort ( ) ;
9341026 }
1027+ drop ( tasks) ;
1028+ drop ( admission) ;
9351029 }
9361030
9371031 /// Abort and remove per-stream tasks. Caller must ensure no further work
@@ -1904,6 +1998,7 @@ mod tests {
19041998 Arc , Mutex ,
19051999 atomic:: { AtomicUsize , Ordering } ,
19062000 } ,
2001+ time:: Duration ,
19072002 } ;
19082003
19092004 use chrono:: { TimeZone , Utc } ;
@@ -1982,6 +2077,33 @@ mod tests {
19822077 )
19832078 }
19842079
2080+ #[ tokio:: test]
2081+ async fn tenant_cleanup_closes_admission_before_draining ( ) {
2082+ let root = Box :: leak ( tempfile:: tempdir ( ) . unwrap ( ) . keep ( ) . into_boxed_path ( ) ) ;
2083+ let ( sender, _receiver) = tokio:: sync:: mpsc:: unbounded_channel ( ) ;
2084+ let manager: & ' static HotTierManager =
2085+ Box :: leak ( Box :: new ( HotTierManager :: new ( root, sender) ) ) ;
2086+ let tenant = "hot-tier-cleanup-admission-test" . to_owned ( ) ;
2087+ let gate = manager. tenant_gate ( & tenant) . await ;
2088+ let admission = gate. clone ( ) . read_owned ( ) . await ;
2089+
2090+ let cleanup_tenant = tenant. clone ( ) ;
2091+ let cleanup = tokio:: spawn ( async move {
2092+ manager. cleanup_tenant ( & cleanup_tenant) . await ;
2093+ } ) ;
2094+ tokio:: task:: yield_now ( ) . await ;
2095+ assert ! ( !cleanup. is_finished( ) ) ;
2096+
2097+ drop ( admission) ;
2098+ tokio:: time:: timeout ( Duration :: from_secs ( 1 ) , cleanup)
2099+ . await
2100+ . unwrap ( )
2101+ . unwrap ( ) ;
2102+ assert ! ( * gate. read( ) . await ) ;
2103+
2104+ manager. finish_tenant_cleanup ( & tenant) . await ;
2105+ }
2106+
19852107 #[ tokio:: test]
19862108 async fn disk_budget_reports_threshold_deficit ( ) {
19872109 let budget = disk_budget ( ) ;
0 commit comments