@@ -1258,27 +1258,55 @@ impl IngesterService for Ingester {
12581258impl EventSubscriber < ShardPositionsUpdate > for WeakIngesterState {
12591259 #[ instrument( name = "ingester.shard_positions_gossip" , skip_all) ]
12601260 async fn handle_event ( & mut self , shard_positions_update : ShardPositionsUpdate ) {
1261+ // Truncating/deleting shards holds the full ingester lock, which `persist` and
1262+ // `init_shards` also need. Doing the whole update under a single lock can stall ingestion
1263+ // on this node for the entire pass (many shards, or a slow WAL). Process it in small
1264+ // batches, releasing the lock between them so writers can interleave. Safe to re-acquire
1265+ // mid-update: `truncate_shard` re-looks up the shard and only advances the truncation
1266+ // position, and `delete_shard` treats a missing WAL queue as already deleted.
1267+ const GOSSIP_TRUNCATE_BATCH_SIZE : usize = 8 ;
1268+
12611269 let Some ( state) = self . upgrade ( ) else {
12621270 warn ! ( "ingester state update failed" ) ;
12631271 return ;
12641272 } ;
1265- let Ok ( mut state_guard) = state. lock_fully ( "truncate_shards_gossip" ) . await else {
1266- error ! ( "failed to lock the ingester state" ) ;
1267- return ;
1268- } ;
1273+
12691274 let index_uid = shard_positions_update. source_uid . index_uid ;
12701275 let source_id = shard_positions_update. source_uid . source_id ;
12711276
1272- for ( shard_id, shard_position) in shard_positions_update. updated_shard_positions {
1273- let queue_id = queue_id ( & index_uid, & source_id, & shard_id) ;
1274- if shard_position. is_eof ( ) {
1275- state_guard. delete_shard ( & queue_id, "indexer gossip" ) . await ;
1276- } else if !shard_position. is_beginning ( ) {
1277- state_guard
1278- . truncate_shard ( & queue_id, shard_position, "indexer gossip" )
1279- . await ;
1277+ let mut updated_shard_positions =
1278+ shard_positions_update. updated_shard_positions . into_iter ( ) ;
1279+ loop {
1280+ let batch: Vec < ( ShardId , Position ) > = updated_shard_positions
1281+ . by_ref ( )
1282+ . take ( GOSSIP_TRUNCATE_BATCH_SIZE )
1283+ . collect ( ) ;
1284+ if batch. is_empty ( ) {
1285+ break ;
1286+ }
1287+ let Ok ( mut state_guard) = state. lock_fully ( "truncate_shards_gossip" ) . await else {
1288+ error ! ( "failed to lock the ingester state" ) ;
1289+ return ;
1290+ } ;
1291+ for ( shard_id, shard_position) in batch {
1292+ let queue_id = queue_id ( & index_uid, & source_id, & shard_id) ;
1293+ if shard_position. is_eof ( ) {
1294+ state_guard. delete_shard ( & queue_id, "indexer gossip" ) . await ;
1295+ } else if !shard_position. is_beginning ( ) {
1296+ state_guard
1297+ . truncate_shard ( & queue_id, shard_position, "indexer gossip" )
1298+ . await ;
1299+ }
12801300 }
1301+ // Drop the guard (end of scope) and yield so a runnable persist/init_shards task can
1302+ // be polled and take the lock before re-acquire for the next batch.
1303+ tokio:: task:: yield_now ( ) . await ;
12811304 }
1305+
1306+ let Ok ( mut state_guard) = state. lock_fully ( "truncate_shards_gossip" ) . await else {
1307+ error ! ( "failed to lock the ingester state" ) ;
1308+ return ;
1309+ } ;
12821310 state_guard. check_decommissioning_status ( ) . await ;
12831311 }
12841312}
@@ -3949,6 +3977,121 @@ mod tests {
39493977 assert ! ( !state_guard. mrecordlog. queue_exists( & queue_id_02) ) ;
39503978 }
39513979
3980+ #[ tokio:: test]
3981+ async fn test_ingester_truncate_on_shard_positions_update_spans_multiple_batches ( ) {
3982+ // The gossip handler processes updates in batches of `GOSSIP_TRUNCATE_BATCH_SIZE`,
3983+ // releasing the ingester lock between batches. This test drives more shards than a
3984+ // single batch to ensure every shard is still truncated/deleted across the multiple
3985+ // lock acquisitions.
3986+ let ( _ingester_ctx, ingester) = IngesterForTest :: default ( ) . build ( ) . await ;
3987+ let event_broker = EventBroker :: default ( ) ;
3988+ ingester. subscribe ( & event_broker) ;
3989+
3990+ let index_uid = IndexUid :: for_test ( "test-index" , 0 ) ;
3991+ let source_id = SourceId :: from ( "test-source" ) ;
3992+
3993+ let doc_mapping_uid = DocMappingUid :: random ( ) ;
3994+ let doc_mapping_json = format ! (
3995+ r#"{{
3996+ "doc_mapping_uid": "{doc_mapping_uid}"
3997+ }}"#
3998+ ) ;
3999+
4000+ // Require several batches at the current `GOSSIP_TRUNCATE_BATCH_SIZE`.
4001+ const NUM_SHARDS : u64 = 20 ;
4002+
4003+ // Make the update hit both handler paths: every DELETE_EVERY-th shard is deleted (via an
4004+ // EOF position), the rest are truncated. `num_deleted` is how many shards should disappear,
4005+ // used below as the "processing finished" condition (final shard count).
4006+ const DELETE_EVERY : u64 = 5 ;
4007+ let num_deleted = ( 1 ..=NUM_SHARDS ) . filter ( |id| id % DELETE_EVERY == 0 ) . count ( ) ;
4008+
4009+ let now = Instant :: now ( ) ;
4010+ let mut state_guard = ingester. state . lock_fully ( "test" ) . await . unwrap ( ) ;
4011+ for shard_id in 1 ..=NUM_SHARDS {
4012+ let shard = Shard {
4013+ index_uid : Some ( index_uid. clone ( ) ) ,
4014+ source_id : source_id. clone ( ) ,
4015+ shard_id : Some ( ShardId :: from ( shard_id) ) ,
4016+ shard_state : ShardState :: Open as i32 ,
4017+ doc_mapping_uid : Some ( doc_mapping_uid) ,
4018+ ..Default :: default ( )
4019+ } ;
4020+ ingester
4021+ . init_primary_shard (
4022+ & mut state_guard. inner ,
4023+ & mut state_guard. mrecordlog ,
4024+ shard,
4025+ & doc_mapping_json,
4026+ now,
4027+ true ,
4028+ )
4029+ . await
4030+ . unwrap ( ) ;
4031+ let queue_id = queue_id ( & index_uid, & source_id, & ShardId :: from ( shard_id) ) ;
4032+ let records = [
4033+ MRecord :: new_doc ( "test-doc-foo" ) . encode ( ) ,
4034+ MRecord :: new_doc ( "test-doc-bar" ) . encode ( ) ,
4035+ ]
4036+ . into_iter ( ) ;
4037+ state_guard
4038+ . mrecordlog
4039+ . append_records ( & queue_id, None , records)
4040+ . await
4041+ . unwrap ( ) ;
4042+ }
4043+ drop ( state_guard) ;
4044+
4045+ let updated_shard_positions = ( 1 ..=NUM_SHARDS )
4046+ . map ( |shard_id| {
4047+ let position = if shard_id % DELETE_EVERY == 0 {
4048+ Position :: eof ( 1u64 )
4049+ } else {
4050+ Position :: offset ( 0u64 )
4051+ } ;
4052+ ( ShardId :: from ( shard_id) , position)
4053+ } )
4054+ . collect ( ) ;
4055+ event_broker. publish ( ShardPositionsUpdate {
4056+ source_uid : SourceUid {
4057+ index_uid : index_uid. clone ( ) ,
4058+ source_id : source_id. clone ( ) ,
4059+ } ,
4060+ updated_shard_positions,
4061+ } ) ;
4062+
4063+ // The handler acquires the lock once per batch, so poll until it has processed every batch.
4064+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , async {
4065+ loop {
4066+ {
4067+ let state_guard = ingester. state . lock_fully ( "test" ) . await . unwrap ( ) ;
4068+ if state_guard. shards . len ( ) == NUM_SHARDS as usize - num_deleted {
4069+ break ;
4070+ }
4071+ }
4072+ yield_now ( ) . await ;
4073+ }
4074+ } )
4075+ . await
4076+ . expect ( "all gossip updates should be processed across batches" ) ;
4077+
4078+ let state_guard = ingester. state . lock_fully ( "test" ) . await . unwrap ( ) ;
4079+ for shard_id in 1 ..=NUM_SHARDS {
4080+ let queue_id = queue_id ( & index_uid, & source_id, & ShardId :: from ( shard_id) ) ;
4081+ if shard_id % DELETE_EVERY == 0 {
4082+ assert ! ( !state_guard. shards. contains_key( & queue_id) ) ;
4083+ assert ! ( !state_guard. mrecordlog. queue_exists( & queue_id) ) ;
4084+ } else {
4085+ assert ! ( state_guard. shards. contains_key( & queue_id) ) ;
4086+ state_guard. mrecordlog . assert_records_eq (
4087+ & queue_id,
4088+ ..,
4089+ & [ ( 1 , [ 0 , 0 ] , "test-doc-bar" ) ] ,
4090+ ) ;
4091+ }
4092+ }
4093+ }
4094+
39524095 #[ tokio:: test]
39534096 async fn test_ingester_closes_idle_shards ( ) {
39544097 // The `CloseIdleShardsTask` task is already unit tested, so this test ensures the task is
0 commit comments