One namespace stuck in a Zoekt rollout loop blocks progress for 116,915 waiting namespaces
Summary
Zoekt rollout has stalled since 09:40:58 UTC on 2026-09-24. One namespace is stuck in a loop, repeatedly creating replicas with no indices. With batch size 1, it takes the only rollout slot every run, blocking about 116,915 namespaces waiting for their first index. The oldest has waited since 2026-09-07.
Root cause
- The looping namespace's only project namespace has no project (
project_namespace.projectis nil). simulate_replica_planrunsnext unless statswhen there's no project — it skips the index but still plans a replica, so the replica has zero indices.PlanningService#generatepickedsimulate_replica_planovercreate_empty_replicabecausefetch_project_namespaces.exists?was true — butwith_project_statisticsis a preload, not a filter, so it matches project namespaces with no project too.process_indices!callsIndex.insert_all!([]); in Rails 7.2 this returns early without raising, so the replica commits as a success andlast_rollout_failed_atis cleared, not set.- Every run,
cleanup_replicas_without_indicesdeletes this replica before selection, so with batch size 1 it takes the only slot again. - It gets an empty replica again,
re_enqueuefires, and the cycle repeats, blocking everything behind it.
Contributing factors
- Un-indexable since 2026-09-07, yet the rollout moved past it until this morning. Likely, not proven:
with_mismatched_replicashas no ORDER BY within a 5,000-id batch, so while many namespaces in the batch were mismatched others often came first; as the batch emptied, this namespace (reset by cleanup right before selection) started winning every run. zoekt_rollout_batch_sizeis 1 (default 32), so one bad namespace takes 100% of a run's capacity.- Old namespaces rebuilding after index eviction have low ids and are scanned first, so they keep getting processed, which masks the stall.
with_missing_indicescan't tell "waiting" from "looping", so it hid the stall as a normal backlog.
What was ruled out
| Hypothesis | Finding |
|---|---|
| Rollout blocked flag | Ruled out — none flagged. |
| Prior rollout failure | Ruled out — last_rollout_failed_at unset. |
| Sidekiq queue backlog | Ruled out — size and latency both 0. |
| Indexing paused | Ruled out. |
| Search nodes offline | Ruled out — 38/38 online. |
Evidence
- Last backlog index created 09:40:58 UTC 2026-09-24; no backlog progress since.
- Empty replicas for the looping namespace at 12:12:12 and 15:05:21 UTC;
last_rollout_failed_atstayed nil. - Replica ids advanced ~117 between 13:30 and 15:05 UTC while only one real index appeared — the loop likely runs often (indicative, not proof).
- 61 replicas in the last 24 hours: backlog progress before the stall plus eviction rebuilds (the loop's own replicas are deleted by cleanup, so they don't show here).
- The next namespace in line has zero replicas, a project with statistics and a clean plan, but was never rolled out.
RolloutWorker's cron fires on schedule; its scheduled job wasdeferred_by: "database_health_check"(deferred_count: 1).
Unblock
Both are production writes needing a decision first. Either unblocks the rollout until the code fix ships.
- Remove or disable the looping namespace's enabled-namespace record (note:
SaasRolloutEventWorkerre-creates records for eligible namespaces every 2 hours, so this may only be temporary). - Fix or clean up its orphaned project namespace.
Proposal
- In
PlanningService, choosesimulate_replica_planvs.create_empty_replicaby whether a project namespace has a real project with statistics — not just whether one exists. - In
process_indices!, treat an empty indices plan as a failure: setlast_rollout_failed_atand wait out the retry interval. - Raise
zoekt_rollout_batch_sizeabove 1 in steps, after confirming why it was lowered. - Once the deferral signal is known, narrow
RolloutWorker's watched tables. - Optional: query no-replica namespaces directly instead of scanning from the lowest id.
Out of scope: why project namespaces have no project. Track rollout-per-day vs. enabled-per-day to catch stalls sooner.
Open questions
- Which health signal is deferring
RolloutWorkerjobs? - Why is
zoekt_rollout_batch_sizeset to 1? - How many namespaces are stuck in this loop right now (
Search::Zoekt::Replica.without_indices)?
References
ee/app/services/search/zoekt/rollout_service.rb— cleanup before selection.ee/app/services/search/zoekt/selection_service.rb— scans from the lowest id, stops at the batch size.ee/app/models/search/zoekt/enabled_namespace.rb—each_batch_with_mismatched_replicas/with_mismatched_replicas(no ORDER BY within a batch),with_missing_indices.ee/app/services/search/zoekt/planning_service.rb— wrong plan branch.ee/app/services/search/zoekt/provisioning_service.rb— empty insert silently succeeds.app/models/namespace.rb—with_project_statistics, the preload-not-filter.ee/app/workers/search/zoekt/rollout_worker.rb— cron worker.ee/app/workers/search/zoekt/saas_rollout_event_worker.rb— enables namespaces.lib/gitlab/sidekiq_middleware/skip_jobs.rb— health deferral.
Console checks used
missing = Search::Zoekt::EnabledNamespace.with_missing_indices
missing.count; missing.with_rollout_blocked.count; missing.where.not(last_rollout_failed_at: nil).count
Search::Zoekt::EnabledNamespace.where(created_at: 1.day.ago..).count
Search::Zoekt::Replica.where(created_at: 1.day.ago..).count
Search::Zoekt::Index.where('zoekt_enabled_namespace_id > ?', <looping enabled namespace id>).maximum(:created_at)
ns = Search::Zoekt::EnabledNamespace.find(<looping enabled namespace id>)
ns.replicas.map { |r| [r.id, r.state, r.created_at, r.indices.count] }
Namespace.by_root_id(ns.root_namespace_id).project_namespaces.map { |pn| [pn.id, pn.project&.id, pn.project&.statistics.present?] }
Search::Zoekt::PlanningService.plan(enabled_namespaces: [ns], nodes: Search::Zoekt::Node.available_for_search_indexing, max_indices_per_replica: Search::Zoekt::MAX_INDICES_PER_REPLICA)
Search::Zoekt::SelectionService.execute(max_batch_size: 1).enabled_namespaces.map(&:id)
Gitlab::CurrentSettings.zoekt_rollout_batch_size
Sidekiq::Cron::Job.find('search_zoekt_rollout_worker')
Gitlab::SidekiqSharding::Router.route(Search::Zoekt::RolloutWorker) { Sidekiq::ScheduledSet.new.scan('Search::Zoekt::RolloutWorker').map { |j| [j.at, j.item['deferred_by'], j.item['deferred_count']] } }Edited by Ravi Kumar