@@ -81,10 +81,16 @@ enum SyncPhase {
8181
8282type StreamKey = ( Option < String > , String ) ;
8383
84+ struct StreamTasks {
85+ latest : tokio:: task:: JoinHandle < ( ) > ,
86+ historic : tokio:: task:: JoinHandle < ( ) > ,
87+ }
88+
8489pub struct HotTierManager {
8590 filesystem : LocalFileSystem ,
8691 hot_tier_path : & ' static Path ,
8792 state_cache : AsyncRwLock < HashMap < StreamKey , Arc < StreamSyncState > > > ,
93+ tasks : AsyncRwLock < HashMap < StreamKey , StreamTasks > > ,
8894}
8995
9096impl HotTierManager {
@@ -94,6 +100,7 @@ impl HotTierManager {
94100 filesystem : LocalFileSystem :: new ( ) ,
95101 hot_tier_path,
96102 state_cache : AsyncRwLock :: new ( HashMap :: new ( ) ) ,
103+ tasks : AsyncRwLock :: new ( HashMap :: new ( ) ) ,
97104 }
98105 }
99106
@@ -388,6 +395,9 @@ impl HotTierManager {
388395 if !self . check_stream_hot_tier_exists ( stream, tenant_id) {
389396 return Err ( HotTierValidationError :: NotFound ( stream. to_owned ( ) ) . into ( ) ) ;
390397 }
398+ // Stop loops before tearing down the directory so no in-flight tick
399+ // re-creates files mid-delete.
400+ self . abort_stream_tasks ( stream, tenant_id) . await ;
391401 let path = if let Some ( tenant_id) = tenant_id. as_ref ( ) {
392402 self . hot_tier_path . join ( tenant_id) . join ( stream)
393403 } else {
@@ -434,11 +444,9 @@ impl HotTierManager {
434444 Ok ( path)
435445 }
436446
437- /// Spawn two independent loops, one per phase, so Latest and Historic
438- /// tick clocks are independent. A long-running Historic backfill no
439- /// longer delays Latest ticks. Within each phase, the next iteration
440- /// starts only after the previous returns + a sleep, so a phase can
441- /// never overlap itself.
447+ /// Discover hot-tier-enabled streams at boot and spawn a per-stream pair
448+ /// of (Latest, Historic) loops for each. New streams added later acquire
449+ /// their own loops via `spawn_stream_tasks` from the PUT hot-tier handler.
442450 pub fn download_from_s3 < ' a > ( & ' a self ) -> Result < ( ) , HotTierError >
443451 where
444452 ' a : ' static ,
@@ -451,74 +459,96 @@ impl HotTierManager {
451459 "hot tier scheduler starting"
452460 ) ;
453461
462+ let this: & ' static HotTierManager = self ;
463+ tokio:: spawn ( async move {
464+ // pstats hot tier may need to be created on boot before any tasks
465+ // can pick it up.
466+ if let Err ( e) = this. create_pstats_hot_tier ( ) . await {
467+ tracing:: error!( "Skipping pstats hot tier creation because of error: {e}" ) ;
468+ }
469+ let tenants = if let Some ( tenants) = PARSEABLE . list_tenants ( ) {
470+ tenants. into_iter ( ) . map ( Some ) . collect :: < Vec < _ > > ( )
471+ } else {
472+ vec ! [ None ]
473+ } ;
474+ for tenant_id in tenants {
475+ for stream in PARSEABLE . streams . list ( & tenant_id) {
476+ if this. check_stream_hot_tier_exists ( & stream, & tenant_id) {
477+ this. spawn_stream_tasks ( stream, tenant_id. clone ( ) ) . await ;
478+ }
479+ }
480+ }
481+ } ) ;
482+ Ok ( ( ) )
483+ }
484+
485+ /// Spawn (Latest, Historic) loops for a single stream. Idempotent:
486+ /// if tasks already exist for this (tenant, stream), no-op.
487+ pub async fn spawn_stream_tasks ( & ' static self , stream : String , tenant_id : Option < String > ) {
488+ let key: StreamKey = ( tenant_id. clone ( ) , stream. clone ( ) ) ;
489+ {
490+ let tasks = self . tasks . read ( ) . await ;
491+ if let Some ( existing) = tasks. get ( & key)
492+ && !existing. latest . is_finished ( )
493+ && !existing. historic . is_finished ( )
494+ {
495+ return ;
496+ }
497+ }
498+
454499 let latest_interval = Duration :: from_secs ( 60 ) ;
455- let historic_interval = Duration :: from_secs ( historic_min as u64 * 60 ) ;
500+ let historic_interval = Duration :: from_secs (
501+ PARSEABLE . options . hot_tier_historic_sync_minutes . max ( 1 ) as u64 * 60 ,
502+ ) ;
456503
457- let this = self ;
458- tokio:: spawn ( async move {
504+ warn ! ( stream = %stream, tenant = ?tenant_id, "spawning per-stream hot tier tasks" ) ;
505+
506+ let s = stream. clone ( ) ;
507+ let t = tenant_id. clone ( ) ;
508+ let latest = tokio:: spawn ( async move {
459509 loop {
460- warn ! ( phase = ?SyncPhase :: Latest , "hot tier tick fired" ) ;
461- if let Err ( err) = this. sync_hot_tier ( SyncPhase :: Latest ) . await {
462- error ! ( "Error in hot tier latest scheduler: {:?}" , err) ;
510+ warn ! ( stream = %s, tenant = ?t, phase = ?SyncPhase :: Latest , "stream tick fired" ) ;
511+ if let Err ( err) = self
512+ . process_stream ( s. clone ( ) , t. clone ( ) , SyncPhase :: Latest )
513+ . await
514+ {
515+ error ! ( stream = %s, "latest sync error: {err:?}" ) ;
463516 }
464517 tokio:: time:: sleep ( latest_interval) . await ;
465518 }
466519 } ) ;
467520
468- let this = self ;
469- tokio:: spawn ( async move {
521+ let s = stream. clone ( ) ;
522+ let t = tenant_id. clone ( ) ;
523+ let historic = tokio:: spawn ( async move {
470524 loop {
471- warn ! ( phase = ?SyncPhase :: Historic , "hot tier tick fired" ) ;
472- if let Err ( err) = this. sync_hot_tier ( SyncPhase :: Historic ) . await {
473- error ! ( "Error in hot tier historic scheduler: {:?}" , err) ;
525+ warn ! ( stream = %s, tenant = ?t, phase = ?SyncPhase :: Historic , "stream tick fired" ) ;
526+ if let Err ( err) = self
527+ . process_stream ( s. clone ( ) , t. clone ( ) , SyncPhase :: Historic )
528+ . await
529+ {
530+ error ! ( stream = %s, "historic sync error: {err:?}" ) ;
474531 }
475532 tokio:: time:: sleep ( historic_interval) . await ;
476533 }
477534 } ) ;
478- Ok ( ( ) )
479- }
480535
481- /// sync the hot tier files from S3 to the hot tier directory for all streams
482- async fn sync_hot_tier ( & self , phase : SyncPhase ) -> Result < ( ) , HotTierError > {
483- // Before syncing, check if pstats stream was created and needs hot tier
484- if let Err ( e) = self . create_pstats_hot_tier ( ) . await {
485- tracing:: trace!( "Skipping pstats hot tier creation because of error: {e}" ) ;
536+ let mut tasks = self . tasks . write ( ) . await ;
537+ if let Some ( old) = tasks. insert ( key, StreamTasks { latest, historic } ) {
538+ old. latest . abort ( ) ;
539+ old. historic . abort ( ) ;
486540 }
541+ }
487542
488- let mut sync_hot_tier_tasks = FuturesUnordered :: new ( ) ;
489- let tenants = if let Some ( tenants) = PARSEABLE . list_tenants ( ) {
490- tenants. into_iter ( ) . map ( Some ) . collect ( )
491- } else {
492- vec ! [ None ]
493- } ;
494- let mut scheduled = 0usize ;
495- for tenant_id in tenants {
496- for stream in PARSEABLE . streams . list ( & tenant_id) {
497- if self . check_stream_hot_tier_exists ( & stream, & tenant_id) {
498- sync_hot_tier_tasks. push ( self . process_stream (
499- stream,
500- tenant_id. to_owned ( ) ,
501- phase,
502- ) ) ;
503- scheduled += 1 ;
504- }
505- }
506- }
507- warn ! ( phase = ?phase, num_streams = scheduled, "hot tier tick: streams scheduled" ) ;
508-
509- let tick_start = std:: time:: Instant :: now ( ) ;
510- while let Some ( res) = sync_hot_tier_tasks. next ( ) . await {
511- if let Err ( err) = res {
512- error ! ( "Failed to run hot tier sync task {err:?}" ) ;
513- return Err ( err) ;
514- }
543+ /// Abort and remove per-stream tasks. Caller must ensure no further work
544+ /// will be enqueued for the stream after this returns.
545+ async fn abort_stream_tasks ( & self , stream : & str , tenant_id : & Option < String > ) {
546+ let key: StreamKey = ( tenant_id. clone ( ) , stream. to_owned ( ) ) ;
547+ if let Some ( t) = self . tasks . write ( ) . await . remove ( & key) {
548+ t. latest . abort ( ) ;
549+ t. historic . abort ( ) ;
550+ warn ! ( stream = %stream, tenant = ?tenant_id, "aborted per-stream hot tier tasks" ) ;
515551 }
516- warn ! (
517- phase = ?phase,
518- elapsed_ms = tick_start. elapsed( ) . as_millis( ) as u64 ,
519- "hot tier tick complete"
520- ) ;
521- Ok ( ( ) )
522552 }
523553
524554 /// process the hot tier files for the stream
0 commit comments