@@ -188,6 +188,62 @@ impl SequencerStateMachine {
188188 map. remove ( & vshard)
189189 }
190190
191+ /// Arm a catch-up for `vshard` from `index` (min-collapse), so the
192+ /// scheduler-side drain replays committed sequencer entries from there.
193+ ///
194+ /// Called when a scheduler subscribes for a vShard: the sequencer may have
195+ /// already committed (and fanned out to a then-absent sender — silently
196+ /// skipped) epochs for this vShard before the scheduler existed. A fresh
197+ /// node has nothing durably applied to rebuild from, so it would otherwise
198+ /// consider itself caught up and never replay those txns. Arming from the
199+ /// first available committed index makes the drain replay every committed
200+ /// entry for this vShard applied before subscription (idempotent: the
201+ /// scheduler's in-flight guard and Reserve/Release no-ops absorb re-apply).
202+ pub fn arm_catch_up_from ( & self , vshard : u32 , index : u64 ) {
203+ self . record_catch_up ( vshard, index) ;
204+ }
205+
206+ /// Read (WITHOUT removing) the catch-up-from Raft index for `vshard`.
207+ ///
208+ /// The scheduler drain peeks rather than takes so a replay that cannot
209+ /// complete this tick (committed index not yet known, transient log-read
210+ /// fault) leaves the entry armed for the next tick instead of silently
211+ /// dropping it — the loss the old take-then-early-return had.
212+ pub fn peek_catch_up_from ( & self , vshard : u32 ) -> Option < u64 > {
213+ let map = self . catch_up_from . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
214+ map. get ( & vshard) . copied ( )
215+ }
216+
217+ /// Clear `vshard`'s catch-up entry only if its recorded index is `<= up_to`.
218+ ///
219+ /// Called after a successful replay of `lo ..= up_to`: the recorded miss is
220+ /// now covered, so clear it — unless a concurrent drop has already lowered
221+ /// the entry to an index the just-finished replay did not cover (only
222+ /// possible for an index `<= up_to` given min-collapse, hence the guard is a
223+ /// belt-and-braces no-op in that case). A newer drop recorded at an index
224+ /// `> up_to` is preserved for the next drain.
225+ pub fn clear_catch_up_up_to ( & self , vshard : u32 , up_to : u64 ) {
226+ let mut map = self . catch_up_from . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
227+ if let Some ( & idx) = map. get ( & vshard)
228+ && idx <= up_to
229+ {
230+ map. remove ( & vshard) ;
231+ }
232+ }
233+
234+ /// The smallest armed catch-up index across ALL vShards, or `None` when no
235+ /// catch-up is pending.
236+ ///
237+ /// The sequencer-group log compactor floors its compaction index at this
238+ /// value so a dropped/undelivered fan-out is always replayable from the
239+ /// retained log — the hold-down the scheduler-side drain's `LogCompacted`
240+ /// arm depends on. Only hosted vShards ever arm a catch-up, so this never
241+ /// pins compaction on a vShard this node does not serve.
242+ pub fn min_catch_up_from ( & self ) -> Option < u64 > {
243+ let map = self . catch_up_from . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
244+ map. values ( ) . copied ( ) . min ( )
245+ }
246+
191247 /// Apply a committed Raft log entry.
192248 ///
193249 /// Decodes the `SequencerEntry`, checks epoch monotonicity, fans out to
@@ -721,6 +777,66 @@ mod tests {
721777 assert_eq ! ( sm. take_catch_up_from( va) , None ) ;
722778 }
723779
780+ /// PEEK must not consume: the scheduler drain reads the armed index, and
781+ /// only clears it after a confirmed replay. A take-then-early-return (the
782+ /// old shape) silently lost the miss when the replay could not complete.
783+ #[ test]
784+ fn peek_catch_up_from_does_not_consume ( ) {
785+ let ( batch, va, _vb) = make_batch_with_two_vshards ( ) ;
786+ let ( tx_a, _rx_a) = mpsc:: channel ( 1 ) ;
787+ let _ = tx_a. try_send ( SchedulerInput :: Txn ( batch. txns [ 0 ] . clone ( ) ) ) ;
788+ let mut senders = HashMap :: new ( ) ;
789+ senders. insert ( va, tx_a) ;
790+ let mut sm = SequencerStateMachine :: new ( senders, CalvinCompletionRegistry :: new_detached ( ) ) ;
791+
792+ sm. apply ( 9 , & encode_entry ( & SequencerEntry :: EpochBatch { batch } ) ) ;
793+
794+ // Repeated peeks keep returning the same armed index.
795+ assert_eq ! ( sm. peek_catch_up_from( va) , Some ( 9 ) ) ;
796+ assert_eq ! ( sm. peek_catch_up_from( va) , Some ( 9 ) ) ;
797+ }
798+
799+ /// Clearing is bounded by the replayed upper bound: a miss covered by the
800+ /// replay is cleared, one recorded ABOVE it survives for the next drain.
801+ #[ test]
802+ fn clear_catch_up_up_to_respects_replayed_upper_bound ( ) {
803+ let senders = HashMap :: new ( ) ;
804+ let sm = SequencerStateMachine :: new ( senders, CalvinCompletionRegistry :: new_detached ( ) ) ;
805+ let v = 42u32 ;
806+
807+ // Armed at 5, replay covered through 10 → cleared.
808+ sm. arm_catch_up_from ( v, 5 ) ;
809+ sm. clear_catch_up_up_to ( v, 10 ) ;
810+ assert_eq ! ( sm. peek_catch_up_from( v) , None ) ;
811+
812+ // Armed at 20, replay only covered through 10 → still armed.
813+ sm. arm_catch_up_from ( v, 20 ) ;
814+ sm. clear_catch_up_up_to ( v, 10 ) ;
815+ assert_eq ! ( sm. peek_catch_up_from( v) , Some ( 20 ) ) ;
816+ }
817+
818+ /// The sequencer-log compaction hold-down floors on the LOWEST armed index
819+ /// across all vShards, so no replica's replay range is compacted away.
820+ #[ test]
821+ fn min_catch_up_from_is_lowest_armed_index_across_vshards ( ) {
822+ let senders = HashMap :: new ( ) ;
823+ let sm = SequencerStateMachine :: new ( senders, CalvinCompletionRegistry :: new_detached ( ) ) ;
824+ assert_eq ! ( sm. min_catch_up_from( ) , None ) ;
825+
826+ sm. arm_catch_up_from ( 1 , 30 ) ;
827+ sm. arm_catch_up_from ( 2 , 12 ) ;
828+ sm. arm_catch_up_from ( 3 , 25 ) ;
829+ assert_eq ! ( sm. min_catch_up_from( ) , Some ( 12 ) ) ;
830+
831+ // Draining the lowest lifts the floor to the next outstanding miss.
832+ sm. clear_catch_up_up_to ( 2 , 12 ) ;
833+ assert_eq ! ( sm. min_catch_up_from( ) , Some ( 25 ) ) ;
834+
835+ sm. clear_catch_up_up_to ( 1 , 30 ) ;
836+ sm. clear_catch_up_up_to ( 3 , 25 ) ;
837+ assert_eq ! ( sm. min_catch_up_from( ) , None ) ;
838+ }
839+
724840 #[ test]
725841 fn catch_up_from_records_dropped_index_on_closed_channel ( ) {
726842 let ( batch, va, vb) = make_batch_with_two_vshards ( ) ;
0 commit comments