Repository navigation
Description
Describe the bug
A CreateIndexRequest that carries at least one source is persisted in the metastore, after which the reply is withheld until the next indexing plan rebuild completes. Completion of that rebuild is signaled by a generation counter that advances only once every ApplyIndexingPlan RPC dispatched by the rebuild has finished. Apply RPCs to Ready indexers have no deadline, so a single Ready indexer that leaves its apply RPC pending is enough to keep the generation from ever advancing. The caller then never receives a reply, even though the index already exists in the metastore and a retry fails with AlreadyExists.
Because the same indexer is part of every later rebuild, later generations are blocked as well: each rebuild dispatches another apply RPC to that indexer and waits for it to finish. Restarting the control plane does not clear the condition while the indexer remains in the indexer pool.
Two properties of the current code make the pending state realistic. First, the indexer-side handler performs metastore queries, pipeline spawning, and pipeline shutdown, of which the last step waits for pipeline actors through pipeline_handle.kill().await. Second, gRPC keepalive is disabled unless grpc.keep_alive is configured, so a blackholed connection, or a peer that accepts requests without answering them, leaves the RPC pending indefinitely.
The observable behavior depends on the client. quickwit-rest-client applies a 10 second default timeout to non-search requests, so quickwit index create reports a timeout, and retries report AlreadyExists while the new source has not been scheduled on the affected indexer. Clients that disable the timeout wait forever.
This behavior is distinct from #6601, which describes a plan that cannot converge and is re-sent repeatedly, and from #6553 and #6509, which cover indexer pool updates and shard ownership. Here the plan converges in principle; the reply is blocked because one apply RPC never finishes.
Root cause
The excerpts below trace the chain at commit 7b256dd3. Line ranges refer to that commit.
1. The create-index handler moves its reply into a task that waits for the next plan rebuild.
// quickwit-control-plane/src/control_plane.rs:556-599
async fn handle_message(
&mut self,
request: CreateIndexRequest,
reply: impl FnOnce(Self::Reply) + Send + Sync + 'static,
ctx: &ActorContext<Self>,
) -> Result<(), ActorExitStatus> {
...
let should_rebuild_plan = !index_metadata.sources.is_empty();
self.model.add_index(index_metadata);
if should_rebuild_plan {
let rebuild_plan_notifier = self.rebuild_plan_debounced(ctx);
tokio::task::spawn(async move {
rebuild_plan_notifier.await;
reply(Ok(response));
});
} else {
reply(Ok(response));
}
...
}2. The awaited future is the next rebuild generation.
// quickwit-control-plane/src/control_plane.rs:390-401
fn rebuild_plan_debounced(
&mut self,
ctx: &ActorContext<Self>,
) -> impl Future<Output = ()> + use<> {
let next_rebuild_waiter = self
.indexing_scheduler
.next_rebuild_tracker
.next_rebuild_waiter();
self.rebuild_plan_debouncer
.self_send_with_cooldown::<RebuildPlan>(ctx);
next_rebuild_waiter
}3. The waiter resolves once the published generation reaches the generation captured at call time.
// quickwit-control-plane/src/indexing_scheduler/change_tracker.rs:43-56
pub fn next_rebuild_waiter(&mut self) -> impl std::future::Future<Output = ()> + use<> {
let mut generation_processed_rx = self.generation_processed_rx.clone();
let current_generation = self.generation;
async move {
loop {
if *generation_processed_rx.borrow() >= current_generation {
return;
}
if generation_processed_rx.changed().await.is_err() {
return;
}
}
}
}4. The generation is published only when the last notifier clone of that rebuild is dropped.
// quickwit-control-plane/src/indexing_scheduler/change_tracker.rs:58-81
pub fn start_rebuild(&mut self) -> Arc<NotifyChangeOnDrop> {
let generation = self.generation;
self.generation += 1;
Arc::new(NotifyChangeOnDrop {
generation,
generation_processed_tx: self.generation_processed_tx.clone(),
})
}
...
impl Drop for NotifyChangeOnDrop {
fn drop(&mut self) {
if self.generation < *self.generation_processed_tx.borrow() {
return;
}
let _ = self.generation_processed_tx.send(self.generation);
}
}5. Each rebuild hands its notifier to the plan application step.
// quickwit-control-plane/src/indexing_scheduler/mod.rs:402-412
fn rebuild_plan_with_indexers(
&mut self,
model: &ControlPlaneModel,
indexers: Vec<IndexerPoolEntry>,
indexer_statuses: FnvHashMap<NodeId, IngesterStatus>,
) {
SCHEDULE_TOTAL.inc();
let notify_on_drop = self.next_rebuild_tracker.start_rebuild();
...6. One apply task is spawned per eligible indexer, each holding a clone of the notifier, and only draining indexers have a deadline.
// quickwit-control-plane/src/indexing_scheduler/mod.rs:580-634
// Retiring and decommissioning indexers still receive the plan so they can gracefully shut
// down dropped pipelines; other states (initializing, decommissioned, failed) are skipped.
for indexer in self.indexer_pool.values().into_iter().filter(|indexer| {
matches!(
indexer.ingester_status,
IngesterStatus::Ready | IngesterStatus::Retiring | IngesterStatus::Decommissioning
)
}) {
...
// We don't want to block on a slow indexer so we apply this change asynchronously.
// Retiring/decommissioning indexers are time-bound, so a slow or unreachable
// draining node can't hold the notify guard. Ready indexers get no timeout.
let apply_deadline = matches!(
indexer.ingester_status,
IngesterStatus::Retiring | IngesterStatus::Decommissioning
)
.then_some(APPLY_INDEXING_PLAN_TIMEOUT);
let notify_on_drop = notify_on_drop.clone();
let indexing_plan_id = indexing_plan_id.clone();
tokio::spawn(async move {
let client = indexer.client.clone();
let apply_plan_fut = client.apply_indexing_plan(ApplyIndexingPlanRequest {
indexing_tasks,
indexing_plan_id,
});
let apply_result = match apply_deadline {
Some(timeout) => tokio::time::timeout(timeout, apply_plan_fut).await,
None => Ok(apply_plan_fut.await),
};
...
drop(notify_on_drop);
});
}7. The indexer-side apply handler is bounded by the work it performs.
// quickwit-indexing/src/actors/indexing_service.rs:681-695
let pipeline_diff = self.compute_pipeline_diff(&plan_request.indexing_tasks);
if !pipeline_diff.pipelines_to_shutdown.is_empty() {
self.shutdown_pipelines(&pipeline_diff.pipelines_to_shutdown)
.await;
}
let mut spawn_pipeline_failures: Vec<IndexingPipelineId> = Vec::new();
if !pipeline_diff.pipelines_to_spawn.is_empty() {
spawn_pipeline_failures = self
.spawn_pipelines(&pipeline_diff.pipelines_to_spawn, ctx)
.await?;
}
self.assign_shards_to_pipelines(&plan_request).await;
self.update_chitchat_running_plan().await;8. Pipeline shutdown waits for the pipeline actors, so an apply call can take as long as a teardown.
// quickwit-indexing/src/actors/indexing_service.rs:819-824
for pipeline_to_shutdown in pipelines_to_shutdown {
match self.detach_indexing_pipeline(pipeline_to_shutdown).await {
Ok(pipeline_handle) => {
// Killing the pipeline ensures that all the pipeline actors will stop.
pipeline_handle.kill().await;
}9. The unbounded behavior for Ready indexers is intentional, and it is asserted upstream.
// quickwit-control-plane/src/indexing_scheduler/mod.rs:1971-1978
#[tokio::test]
async fn test_apply_plan_times_out_only_for_draining_indexers() {
// A ready indexer is unbounded: the hanging apply is never cancelled, so its task never
// finishes (a wrongly-applied timeout would fire well within 500ms and flip this).
assert!(
!hanging_apply_is_cancelled_within(IngesterStatus::Ready, Duration::from_millis(500))
.await
);The chain is therefore complete: (1) moves the reply into a task waiting on (2), which observes the generation counter in (3); the counter advances only through the notifier drop in (4); the notifier is created in (5) and cloned into one apply task per indexer in (6); the Ready case has no deadline, and the handler in (7) and (8) can remain pending; consequently no generation is published, and each later rebuild repeats (6) through (8).
Steps to reproduce (if applicable)
The reproduction is a deterministic integration test that uses only public APIs and the test suite mocks. It requires no object store and no network. It spawns a control plane with a single Ready indexer whose apply_indexing_plan returns std::future::pending(), configures a metastore mock that returns an index carrying one Kafka source, sends a CreateIndexRequest to the control plane mailbox, and advances the accelerated clock.
- The test asserts that
apply_indexing_planhas been dispatched to the Ready indexer. - It then asserts that the
create_indexreply is still pending after repeated rebuilds, while the index already exists in the metastore. - A companion test replaces the hanging indexer with a responsive one and asserts that the reply resolves, which isolates the pending apply as the cause.
Paste the file below into quickwit/quickwit-control-plane/tests/control_plane_create_index_rebuild_hang.rs and run:
cargo test -p quickwit-control-plane --test control_plane_create_index_rebuild_hang -- --nocapture
Reproduction test
//! Reproduction for: the control plane never replies to `create_index` while a Ready
//! indexer leaves its `ApplyIndexingPlan` RPC pending.
//!
//! Paste into `quickwit/quickwit-control-plane/tests/control_plane_create_index_rebuild_hang.rs`.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use quickwit_actors::{ActorHandle, Mailbox, Supervisor, Universe};
use quickwit_config::{ClusterConfig, IndexConfig, KafkaSourceParams, SourceConfig, SourceParams};
use quickwit_control_plane::control_plane::ControlPlane;
use quickwit_control_plane::{IndexerPool, IndexerPoolEntry};
use quickwit_ingest::IngesterPool;
use quickwit_metastore::{CreateIndexRequestExt, IndexMetadata, ListIndexesMetadataResponseExt};
use quickwit_proto::indexing::{
ApplyIndexingPlanRequest, ApplyIndexingPlanResponse, CpuCapacity, IndexingResult,
IndexingService, IndexingServiceClient,
};
use quickwit_proto::ingest::ingester::IngesterStatus;
use quickwit_proto::metastore::{
CreateIndexRequest, CreateIndexResponse, ListIndexesMetadataRequest,
ListIndexesMetadataResponse, MetastoreServiceClient, MockMetastoreService,
};
use quickwit_proto::types::NodeId;
/// An `IndexingService` whose `apply_indexing_plan` future never resolves.
#[derive(Debug)]
struct HangingIndexingService {
apply_count: Arc<AtomicUsize>,
}
#[async_trait]
impl IndexingService for HangingIndexingService {
async fn apply_indexing_plan(
&self,
_request: ApplyIndexingPlanRequest,
) -> IndexingResult<ApplyIndexingPlanResponse> {
self.apply_count.fetch_add(1, Ordering::SeqCst);
std::future::pending().await
}
}
/// An `IndexingService` that acknowledges plans immediately.
#[derive(Debug)]
struct RespondingIndexingService {
apply_count: Arc<AtomicUsize>,
}
#[async_trait]
impl IndexingService for RespondingIndexingService {
async fn apply_indexing_plan(
&self,
_request: ApplyIndexingPlanRequest,
) -> IndexingResult<ApplyIndexingPlanResponse> {
self.apply_count.fetch_add(1, Ordering::SeqCst);
Ok(ApplyIndexingPlanResponse {})
}
}
fn kafka_source_config() -> SourceConfig {
SourceConfig::for_test(
"test-source",
SourceParams::Kafka(KafkaSourceParams {
topic: "test-topic".to_string(),
client_log_level: None,
client_params: serde_json::json!({
"bootstrap.servers": "localhost:9092",
}),
enable_backfill_mode: false,
}),
)
}
/// Spawns a control plane with one Ready indexer and a metastore that creates an index carrying
/// one schedulable Kafka source, then waits for the control plane to become ready.
async fn spawn_control_plane(
universe: &Universe,
indexer_client: IndexingServiceClient,
) -> (
Mailbox<ControlPlane>,
CreateIndexRequest,
ActorHandle<Supervisor<ControlPlane>>,
) {
let indexer_node_id = NodeId::from_str("test-indexer");
let indexer_pool = IndexerPool::default();
indexer_pool.insert(
indexer_node_id.clone(),
IndexerPoolEntry {
node_id: indexer_node_id,
generation_id: 0,
client: indexer_client,
indexing_tasks: Vec::new(),
indexing_capacity: CpuCapacity::from_cpu_millis(4_000),
ingester_status: IngesterStatus::Ready,
availability_zone: None,
},
);
let source_config = kafka_source_config();
let mut index_metadata = IndexMetadata::for_test("test-index", "ram:///indexes/test-index");
index_metadata.add_source(source_config.clone()).unwrap();
let index_uid = index_metadata.index_uid.clone();
let index_metadata_json = serde_json::to_string(&index_metadata).unwrap();
let mut mock_metastore = MockMetastoreService::new();
mock_metastore
.expect_list_indexes_metadata()
.returning(|request: ListIndexesMetadataRequest| {
assert_eq!(request, ListIndexesMetadataRequest::all());
Ok(ListIndexesMetadataResponse::for_test(Vec::new()))
});
mock_metastore.expect_create_index().returning(move |_request| {
Ok(CreateIndexResponse {
index_uid: Some(index_uid.clone()),
index_metadata_json: index_metadata_json.clone(),
})
});
let index_config = IndexConfig::for_test("test-index", "ram:///indexes/test-index");
let create_index_request =
CreateIndexRequest::try_from_index_and_source_configs(&index_config, &[source_config])
.unwrap();
let (control_plane_mailbox, control_plane_handle, mut readiness_rx) = ControlPlane::spawn(
universe,
ClusterConfig::for_test(),
NodeId::from_str("test-control-plane"),
indexer_pool,
IngesterPool::default(),
MetastoreServiceClient::from_mock(mock_metastore),
);
tokio::time::timeout(
Duration::from_secs(5),
readiness_rx.wait_for(|readiness| *readiness),
)
.await
.expect("control plane should become ready")
.expect("readiness watch should stay open");
(
control_plane_mailbox,
create_index_request,
control_plane_handle,
)
}
/// Demonstrates that one unresponsive Ready indexer is enough to keep the `create_index` reply
/// pending forever.
#[tokio::test]
async fn create_index_reply_is_blocked_by_a_hanging_ready_indexer_apply() {
let universe = Universe::with_accelerated_time();
let apply_count = Arc::new(AtomicUsize::new(0));
let indexer_client = IndexingServiceClient::tower().build(HangingIndexingService {
apply_count: apply_count.clone(),
});
let (control_plane_mailbox, create_index_request, _control_plane_handle) =
spawn_control_plane(&universe, indexer_client).await;
let create_index_task =
tokio::spawn(async move { control_plane_mailbox.ask(create_index_request).await });
// Advance the accelerated clock until the plan rebuild dispatches `apply_indexing_plan` to
// the Ready indexer.
let mut apply_dispatched = false;
for _ in 0..80 {
if apply_count.load(Ordering::SeqCst) > 0 {
apply_dispatched = true;
break;
}
universe.sleep(Duration::from_millis(250)).await;
}
assert!(
apply_dispatched,
"the control plane should dispatch `apply_indexing_plan` to the Ready indexer"
);
println!(
"[apply] dispatched {} `apply_indexing_plan` call(s) to the hanging Ready indexer",
apply_count.load(Ordering::SeqCst)
);
// The apply future never completes, so the rebuild generation is never published and the
// deferred `create_index` reply must remain pending.
for _ in 0..80 {
if create_index_task.is_finished() {
break;
}
universe.sleep(Duration::from_millis(250)).await;
}
assert!(
!create_index_task.is_finished(),
"the `create_index` reply resolved even though a Ready indexer apply never completed"
);
println!(
"[blocked] `create_index` reply is still pending after {} apply call(s); the index \
itself was already created in the metastore",
apply_count.load(Ordering::SeqCst)
);
universe.assert_quit().await;
}
/// Control case: with a responsive indexer, the `create_index` reply does resolve.
#[tokio::test]
async fn create_index_reply_resolves_when_the_ready_indexer_answers() {
let universe = Universe::with_accelerated_time();
let apply_count = Arc::new(AtomicUsize::new(0));
let indexer_client = IndexingServiceClient::tower().build(RespondingIndexingService {
apply_count: apply_count.clone(),
});
let (control_plane_mailbox, create_index_request, _control_plane_handle) =
spawn_control_plane(&universe, indexer_client).await;
let create_index_task =
tokio::spawn(async move { control_plane_mailbox.ask(create_index_request).await });
let mut resolved = false;
for _ in 0..80 {
if create_index_task.is_finished() {
resolved = true;
break;
}
universe.sleep(Duration::from_millis(250)).await;
}
assert!(
resolved,
"with a responsive indexer the `create_index` reply must resolve"
);
let reply = create_index_task.await.unwrap();
assert!(reply.is_ok(), "the reply channel should stay open");
assert!(reply.unwrap().is_ok(), "the created index should be reported");
println!(
"[control] `create_index` reply resolved after {} apply call(s)",
apply_count.load(Ordering::SeqCst)
);
universe.assert_quit().await;
}Observed result
Compiling quickwit-control-plane v0.9.1 (/path/to/quickwit/quickwit/quickwit-control-plane)
Finished `test` profile [unoptimized] target(s) in 2.65s
Running tests/control_plane_create_index_rebuild_hang.rs (target/debug/deps/control_plane_create_index_rebuild_hang-73e0b873e330f44e)
running 2 tests
[apply] dispatched 1 `apply_indexing_plan` call(s) to the hanging Ready indexer
[control] `create_index` reply resolved after 2 apply call(s)
test create_index_reply_resolves_when_the_ready_indexer_answers ... ok
[blocked] `create_index` reply is still pending after 2 apply call(s); the index itself was already created in the metastore
test create_index_reply_is_blocked_by_a_hanging_ready_indexer_apply ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
Expected behavior
A create-index request should not be coupled to the completion of the entire indexing plan rebuild, or that coupling should be bounded. Reasonable options include:
- replying once the metastore operation has completed and scheduling the new source asynchronously, since the rebuild is already triggered without waiting elsewhere;
- keeping the coupling but adding a deadline to the reply wait, so that a slow or unresponsive indexer results in a delayed reply rather than an unbounded one;
- extending apply deadlines to Ready indexers, or publishing the rebuild generation when the apply RPCs are dispatched and tracking their completion separately, so that a single pending call cannot hold the notifier.
Whichever option is chosen, a regression test in the style of the reproduction above should accompany it.
Configuration:
- Output of
quickwit --version:quickwit 0.9.1, commit7b256dd338cc5b9cf850c3c88b46c0bb22d25974. - The index config: not specific to the issue, except that the created index must carry at least one source for
should_rebuild_planto be set. Any scheduled source type qualifies, including Kafka, Kinesis, PubSub, Pulsar, and native ingest sources with open shards. - Deployment: control plane enabled;
grpc.keep_aliveunset, which is the default; client timeouts at their defaults, withquickwit-rest-clientusing 10 seconds for non-search requests.
Activity
aayushbaluni commentedon Sep 30, 2026
The chain reproduces as described on
main. One thing that I think should shape which of your three options gets picked: the missing deadline on Ready indexers is deliberate and commented, not an oversight.indexing_scheduler/mod.rs:593-601:So
APPLY_INDEXING_PLAN_TIMEOUTexists and is applied deliberately to exactly the draining states, with "Ready indexers get no timeout" written out. Your third option — "extending apply deadlines to Ready indexers" — is therefore a reversal of a recorded decision rather than filling a gap, and whoever wrote that comment presumably had a reason for exempting Ready nodes (a Ready indexer being slow to apply is not the same failure class as one that is draining, and timing it out means dropping a plan a healthy node was still working through).That pushes me toward your first option as the one that does not touch the apply path at all: reply once the metastore write has completed and let the rebuild proceed asynchronously. As you note, the rebuild is already triggered without waiting elsewhere, so the coupling in
create_indexlooks like the outlier rather than the invariant. It also fixes the symptom users actually hit —quickwit index createtiming out at the rest-client's 10s and then reportingAlreadyExistson retry — without changing what happens to a slow Ready indexer.Your second option (a deadline on the reply wait) is the smallest change but leaves the generation counter stuck, so the next create would still be blocked behind the same indexer; it bounds the symptom rather than the cause.
Two things I checked but could not settle from reading alone:
create_indexreplying only after the plan has been applied — a caller that assumes the source is schedulable the moment the reply lands would notice option 1.should_rebuild_planbeing set only when the index carries a source suggests the coupling was about scheduling the new source, so this is the part I would want a maintainer's read on.notify_on_dropguard has other holders that would keep a generation from advancing even with the apply RPCs bounded.Happy to implement option 1 with a regression test in the style of your reproduction if that is the direction — but the choice between "decouple the reply" and "bound the apply" is a semantics call on the control plane, and I would rather not pick it unilaterally given the comment above shows the current asymmetry was intentional.
For what it is worth, #6842 from the same audit was unambiguous and I have opened #6850 for it.
aayushbaluni commentedon Oct 1, 2026
Opened #6857 implementing option 1 from the list above — reply once the metastore write completes, trigger the rebuild without awaiting it.
To be clear about why I did not wait for a direction: it is the one option that does not touch the apply path, so it leaves the recorded
Ready-indexer decision alone, andrebuild_plan_debouncedalready separates triggering from waiting — the waiter it returns is a clonedwatch::Receiver, so dropping it cannot affect the rebuild. That made it small enough to be worth showing rather than describing. Please close it freely if you would rather bound the reply wait or extend apply deadlines; I will implement whichever you pick.Two things I checked that bear on the choice, since I raised them as open questions earlier:
Nothing observable depends on the coupling.
self.model.add_indexalready runs before the reply on both branches, so the index and its sources are visible to the control plane by the time the caller gets a response.test_control_plane_handles_rebalance_shards_callbackandtest_control_plane_get_debug_infoboth create an index with aningest_v2source and then immediately open shards; both still pass.I could not build a regression test that fails on the old code, and the PR says so rather than shipping one that implies otherwise. An undrained test mailbox does not reproduce it — the actor
asktimeout fires instantly under accelerated time, so the apply completes with an error. A directIndexingServicewhoseapply_indexing_planisstd::future::pending().awaitalso passed unfixed, which suggests no apply is dispatched at all when the plan for that indexer has not changed. If there is a short way to force a dispatch in a unit test, I will add it.