Skip to content

Commit fc7ed60

Browse files
ingest: batch WAL truncation in the shard-positions gossip handler
Fixes #6598
1 parent a5ad540 commit fc7ed60

1 file changed

Lines changed: 155 additions & 12 deletions

File tree

quickwit/quickwit-ingest/src/ingest_v2/ingester.rs

Lines changed: 155 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,27 +1258,55 @@ impl IngesterService for Ingester {
12581258
impl 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

Comments
 (0)