@@ -567,6 +567,43 @@ impl TaskExecutor {
567567 Ok ( ( ) )
568568 }
569569
570+ /// Register periodic work that is **not** driven by a message.
571+ ///
572+ /// Runs `work` on its own task every `every`. Use it for work only the tick
573+ /// performs: a ticker registered through [`MessageHandler::tickers`] shares
574+ /// the handler's message loop, where a mailbox that never empties can keep
575+ /// it from running.
576+ ///
577+ /// Cancellation and shutdown match [`Self::add_handler`]: the task observes
578+ /// the same token and is joined by [`Self::shutdown_all`].
579+ pub fn add_periodic < F , Fut > ( & self , name : String , every : Duration , mut work : F ) -> Result < ( ) >
580+ where
581+ F : FnMut ( ) -> Fut + Send + ' static ,
582+ Fut : std:: future:: Future < Output = ( ) > + Send ,
583+ {
584+ let cancellation_token = self . cancellation_token . clone ( ) ;
585+ let task_name = name. clone ( ) ;
586+ let handle = tokio:: spawn ( async move {
587+ let mut interval = interval_at ( tokio:: time:: Instant :: now ( ) + every, every) ;
588+ // Same reason as the dispatcher: `Burst` would replay every tick
589+ // missed while `work` ran, which for a slow `work` means the timer
590+ // is permanently ready and the cancellation arm never wins.
591+ interval. set_missed_tick_behavior ( tokio:: time:: MissedTickBehavior :: Delay ) ;
592+ loop {
593+ tokio:: select! {
594+ biased;
595+ _ = cancellation_token. cancelled( ) => {
596+ debug!( "Periodic task '{}' received cancellation" , task_name) ;
597+ return Ok ( ( ) ) ;
598+ }
599+ _ = interval. tick( ) => work( ) . await ,
600+ }
601+ }
602+ } ) ;
603+ self . tasks . write ( ) . unwrap ( ) . push ( ( name, handle) ) ;
604+ Ok ( ( ) )
605+ }
606+
570607 /// Cancel and join every handler registered by [`Self::add_handler`].
571608 ///
572609 /// Cancellation causes each handler's dispatcher to stop accepting messages and call
@@ -1204,7 +1241,7 @@ struct ResidentMemTables {
12041241 /// resident by a *failed* flush, which are the ones most worth metering.
12051242 frozen : Vec < InMemoryMemTableRef > ,
12061243 /// Sealed memtables whose flush *did* commit, lingering out
1207- /// `frozen_memtable_grace` before `SweepExpired` drops them.
1244+ /// `frozen_memtable_grace` before [`sweep_expired_frozen`] drops them.
12081245 ///
12091246 /// Held apart from `frozen` rather than dropped from the view: no flush can
12101247 /// reclaim these, so metering the flush valve on them would throttle against
@@ -1226,7 +1263,7 @@ struct ResidentMemTables {
12261263/// Re-derive a shard's resident-memtable set from its writer state.
12271264///
12281265/// Call under the write lock after any change to that set: `open`,
1229- /// `freeze_memtable`, a flush commit, and `SweepExpired` — which changes it by
1266+ /// `freeze_memtable`, a flush commit, and [`sweep_expired_frozen`] — which changes it by
12301267/// evicting grace-expired generations that are still counted until it runs.
12311268///
12321269/// Cheap: two `Arc` clones per live memtable, no byte walk — the totals are
@@ -1264,7 +1301,8 @@ struct WriterState {
12641301 /// `frozen_memtable_grace` beyond it so as-of reads stay batch-resolved.
12651302 /// Pushed in `freeze_memtable`; stamped `flushed_at_ms` by `flush_memtable`
12661303 /// on commit success only (retained un-stamped on failure until a later
1267- /// flush or WAL replay on reopen); swept after the grace by `SweepExpired`.
1304+ /// flush or WAL replay on reopen); swept after the grace by
1305+ /// [`sweep_expired_frozen`].
12681306 frozen_memtables : VecDeque < FrozenMemTable > ,
12691307 /// Flag to prevent duplicate memtable flush requests.
12701308 flush_requested : bool ,
@@ -1782,7 +1820,8 @@ impl SharedWriterState {
17821820 // dispatches below. `state.memtable` was already replaced, so a failed
17831821 // send that returned here without this push would drop the table and its
17841822 // accepted rows would silently vanish from every scan. Keep it queryable
1785- // past its manifest commit too (swept after the grace by `SweepExpired`);
1823+ // past its manifest commit too (swept after the grace by
1824+ // `sweep_expired_frozen`);
17861825 // Arc refcount, not a copy — the flush task holds it alive anyway.
17871826 state. frozen_memtables . push_back ( FrozenMemTable {
17881827 memtable : frozen_memtable. clone ( ) ,
@@ -2455,6 +2494,25 @@ impl ShardWriter {
24552494 memtable_flush_rx,
24562495 ) ?;
24572496
2497+ // On its own task: only this sweep reclaims a frozen memtable's bytes,
2498+ // and a ticker on the flush handler can be starved by its mailbox. See
2499+ // `TaskExecutor::add_periodic`.
2500+ //
2501+ // Zero grace evicts on flush commit, so there is nothing to sweep.
2502+ if !config. frozen_memtable_grace . is_zero ( ) {
2503+ // Sweep often enough that eviction lags the grace by at most ~1/3,
2504+ // so a generation lives no more than ~grace * 4/3 past its commit.
2505+ let tick = ( config. frozen_memtable_grace / 3 ) . max ( Duration :: from_millis ( 100 ) ) ;
2506+ let sweep_state = state. clone ( ) ;
2507+ let sweep_memory = memory. clone ( ) ;
2508+ let grace = config. frozen_memtable_grace ;
2509+ task_executor. add_periodic ( "memtable_grace_sweeper" . to_string ( ) , tick, move || {
2510+ let state = sweep_state. clone ( ) ;
2511+ let memory = sweep_memory. clone ( ) ;
2512+ async move { sweep_expired_frozen ( & state, & memory, grace) . await }
2513+ } ) ?;
2514+ }
2515+
24582516 // The index-apply task. Its own channel and its own dispatcher: the
24592517 // dispatcher awaits `handle()` inline, so sharing the WAL flusher's
24602518 // channel would queue every index apply behind a ~100ms S3 PUT.
@@ -3947,7 +4005,8 @@ struct MemTableFlushHandler {
39474005 stats : SharedWriteStats ,
39484006 observer : Option < Arc < dyn WalObserver > > ,
39494007 /// How long a frozen memtable lingers in memory after its flush commits
3950- /// before `SweepExpired` evicts it. See `ShardWriterConfig::frozen_memtable_grace`.
4008+ /// before [`sweep_expired_frozen`] evicts it. See
4009+ /// `ShardWriterConfig::frozen_memtable_grace`.
39514010 grace : Duration ,
39524011}
39534012
@@ -3976,41 +4035,37 @@ impl MemTableFlushHandler {
39764035 grace,
39774036 }
39784037 }
4038+ }
39794039
3980- /// Evict frozen memtables whose post-flush grace has elapsed. Un-stamped
3981- /// (not-yet-flushed) entries are always kept.
3982- async fn sweep_expired_frozen ( & self ) {
3983- let now = now_millis ( ) ;
3984- let grace_ms = self . grace . as_millis ( ) as u64 ;
3985- let mut state = self . state . write ( ) . await ;
3986- let before = state. frozen_memtables . len ( ) ;
3987- state
3988- . frozen_memtables
3989- . retain ( |frozen| match frozen. flushed_at_ms {
3990- Some ( flushed_at) => now. saturating_sub ( flushed_at) < grace_ms,
3991- None => true ,
3992- } ) ;
3993- // Eviction is the only thing that reclaims a grace-retained generation,
3994- // so this is where its bytes leave the memory view.
3995- if state. frozen_memtables . len ( ) != before {
3996- publish_memory ( & self . memory , & state) ;
3997- }
4040+ /// Evict frozen memtables whose post-flush grace has elapsed. Un-stamped
4041+ /// (not-yet-flushed) entries are always kept.
4042+ ///
4043+ /// A free function so the sweeper task can call it without owning the flush
4044+ /// handler — see [`TaskExecutor::add_periodic`] for why it must not run there.
4045+ async fn sweep_expired_frozen (
4046+ state : & Arc < RwLock < WriterState > > ,
4047+ memory : & Arc < ArcSwap < ResidentMemTables > > ,
4048+ grace : Duration ,
4049+ ) {
4050+ let now = now_millis ( ) ;
4051+ let grace_ms = grace. as_millis ( ) as u64 ;
4052+ let mut state = state. write ( ) . await ;
4053+ let before = state. frozen_memtables . len ( ) ;
4054+ state
4055+ . frozen_memtables
4056+ . retain ( |frozen| match frozen. flushed_at_ms {
4057+ Some ( flushed_at) => now. saturating_sub ( flushed_at) < grace_ms,
4058+ None => true ,
4059+ } ) ;
4060+ // Eviction is the only thing that reclaims a grace-retained generation,
4061+ // so this is where its bytes leave the memory view.
4062+ if state. frozen_memtables . len ( ) != before {
4063+ publish_memory ( memory, & state) ;
39984064 }
39994065}
40004066
40014067#[ async_trait]
40024068impl MessageHandler < TriggerMemTableFlush > for MemTableFlushHandler {
4003- fn tickers ( & mut self ) -> Vec < ( Duration , MessageFactory < TriggerMemTableFlush > ) > {
4004- // Zero grace evicts on commit, so no sweeper is needed.
4005- if self . grace . is_zero ( ) {
4006- return vec ! [ ] ;
4007- }
4008- // Sweep often enough that eviction lags the grace by at most ~1/3, so a
4009- // generation lives no more than ~grace * 4/3 past its flush commit.
4010- let tick = ( self . grace / 3 ) . max ( Duration :: from_millis ( 100 ) ) ;
4011- vec ! [ ( tick, Box :: new( || TriggerMemTableFlush :: SweepExpired ) ) ]
4012- }
4013-
40144069 async fn handle ( & mut self , message : TriggerMemTableFlush ) -> Result < ( ) > {
40154070 match message {
40164071 TriggerMemTableFlush :: Flush { memtable, done } => {
@@ -4023,7 +4078,6 @@ impl MessageHandler<TriggerMemTableFlush> for MemTableFlushHandler {
40234078 result?;
40244079 }
40254080 }
4026- TriggerMemTableFlush :: SweepExpired => self . sweep_expired_frozen ( ) . await ,
40274081 }
40284082 Ok ( ( ) )
40294083 }
@@ -4150,7 +4204,7 @@ impl MemTableFlushHandler {
41504204 // Retire the frozen handle on commit success, keyed by generation
41514205 // (non-FIFO completion is fine). Zero grace evicts here; otherwise
41524206 // stamp the grace clock so it lingers for multi-part as-of reads
4153- // until `SweepExpired` . On failure leave it un-stamped: rows stay in
4207+ // until the grace sweep . On failure leave it un-stamped: rows stay in
41544208 // the read union until a later flush or WAL replay, else a transient
41554209 // error reopens the hole.
41564210 if flush_result. is_ok ( ) {
@@ -5767,6 +5821,110 @@ mod tests {
57675821 /// lingers for `frozen_memtable_grace`. A long grace therefore leaves count
57685822 /// non-zero with bytes back at zero — and pins that the two surfaces are
57695823 /// answering different questions, not disagreeing about one.
5824+ /// A saturated handler starves its own ticker; `add_periodic` does not.
5825+ ///
5826+ /// `TaskDispatcher` biases a handler's mailbox ahead of its ticker, and the
5827+ /// `select!` is not evaluated while `handle()` awaits, so a mailbox that
5828+ /// never empties leaves the ticker no instant to win. Both halves are
5829+ /// asserted so the distinction holds.
5830+ #[ tokio:: test( start_paused = true ) ]
5831+ async fn test_saturated_handler_starves_its_ticker_but_not_a_periodic_task ( ) {
5832+ #[ derive( Debug ) ]
5833+ enum Msg {
5834+ Work ,
5835+ Tick ,
5836+ }
5837+
5838+ #[ derive( Debug ) ]
5839+ struct SlowHandler {
5840+ worked : Arc < AtomicUsize > ,
5841+ ticked : Arc < AtomicUsize > ,
5842+ work : Duration ,
5843+ tick : Duration ,
5844+ }
5845+
5846+ #[ async_trait]
5847+ impl MessageHandler < Msg > for SlowHandler {
5848+ fn tickers ( & mut self ) -> Vec < ( Duration , MessageFactory < Msg > ) > {
5849+ vec ! [ ( self . tick, Box :: new( || Msg :: Tick ) ) ]
5850+ }
5851+
5852+ async fn handle ( & mut self , message : Msg ) -> Result < ( ) > {
5853+ match message {
5854+ Msg :: Work => {
5855+ self . worked . fetch_add ( 1 , Ordering :: Relaxed ) ;
5856+ tokio:: time:: sleep ( self . work ) . await ;
5857+ }
5858+ Msg :: Tick => {
5859+ self . ticked . fetch_add ( 1 , Ordering :: Relaxed ) ;
5860+ }
5861+ }
5862+ Ok ( ( ) )
5863+ }
5864+ }
5865+
5866+ // One unit of work outlasts the tick interval: that is what starves a
5867+ // ticker sharing the handler's loop.
5868+ let work = Duration :: from_millis ( 100 ) ;
5869+ let tick = Duration :: from_millis ( 10 ) ;
5870+ let run = Duration :: from_millis ( 1000 ) ;
5871+ let expected_ticks = ( run. as_millis ( ) / tick. as_millis ( ) ) as usize ;
5872+
5873+ let executor = TaskExecutor :: new ( ) ;
5874+ let worked = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
5875+ let ticked = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
5876+ let swept = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
5877+
5878+ let ( tx, rx) = mpsc:: unbounded_channel ( ) ;
5879+ executor
5880+ . add_handler (
5881+ "slow" . to_string ( ) ,
5882+ Box :: new ( SlowHandler {
5883+ worked : worked. clone ( ) ,
5884+ ticked : ticked. clone ( ) ,
5885+ work,
5886+ tick,
5887+ } ) ,
5888+ rx,
5889+ )
5890+ . unwrap ( ) ;
5891+
5892+ // The same cadence, on its own task rather than the handler's loop.
5893+ let swept_by_task = swept. clone ( ) ;
5894+ executor
5895+ . add_periodic ( "sweeper" . to_string ( ) , tick, move || {
5896+ let swept = swept_by_task. clone ( ) ;
5897+ async move {
5898+ swept. fetch_add ( 1 , Ordering :: Relaxed ) ;
5899+ }
5900+ } )
5901+ . unwrap ( ) ;
5902+
5903+ // Enough queued work that the mailbox never empties during the run.
5904+ for _ in 0 ..( run. as_millis ( ) / work. as_millis ( ) ) + 2 {
5905+ tx. send ( Msg :: Work ) . unwrap ( ) ;
5906+ }
5907+ tokio:: time:: sleep ( run) . await ;
5908+
5909+ let ( worked, ticked, swept) = (
5910+ worked. load ( Ordering :: Relaxed ) ,
5911+ ticked. load ( Ordering :: Relaxed ) ,
5912+ swept. load ( Ordering :: Relaxed ) ,
5913+ ) ;
5914+ assert ! ( worked > 0 , "the handler must have been busy" ) ;
5915+ assert ! (
5916+ ticked * 10 < expected_ticks,
5917+ "a ticker sharing a saturated handler's loop should be starved, but \
5918+ it ran {ticked} of ~{expected_ticks}"
5919+ ) ;
5920+ assert ! (
5921+ swept * 2 >= expected_ticks,
5922+ "periodic task ran {swept} of ~{expected_ticks}"
5923+ ) ;
5924+
5925+ executor. shutdown_all ( ) . await . ok ( ) ;
5926+ }
5927+
57705928 #[ tokio:: test]
57715929 async fn test_memtable_stats_frozen_count_outlives_frozen_bytes ( ) {
57725930 let ( store, base_path, base_uri, _temp_dir) = create_local_store ( ) . await ;
@@ -8870,7 +9028,7 @@ mod tests {
88709028 /// On a successful flush commit the sealed generation's rows land in the
88719029 /// manifest immediately, but the in-memory handle is NOT dropped — it
88729030 /// lingers for `frozen_memtable_grace` (so in-flight as-of reads keep
8873- /// batch-resolved membership), then is swept by the `SweepExpired` ticker .
9031+ /// batch-resolved membership), then is swept by the grace sweep .
88749032 #[ tokio:: test]
88759033 async fn test_frozen_retained_during_grace_then_swept ( ) {
88769034 let ( store, base_path, base_uri, _temp_dir) = create_local_store ( ) . await ;
0 commit comments