Fix stale snapshot transfer recovery comment on failure/cancellation (#9237)

* Fix stale snapshot transfer recovery comment on failure/cancellation

The transfer status "comment" prefers the destination-side recovery
progress over the sender task status. Recovery progress was tracked in
`active_recoveries` and only removed via `finish_shard_recovery`, which
was called on the success path only. If `recover_shard_snapshot` returned
early - clearing the local shard, downloading the snapshot, checksum
mismatch, or cancellation (the future is spawned with
`spawn_cancel_on_drop`) - the entry leaked.

A leaked entry keeps reporting its last stage with an ever-growing
elapsed time (computed live from a fixed `Instant`), so subsequent
transfers for the same shard show stale timings until the next recovery
overwrites the entry or the node restarts.

Replace the manual start/finish pair with an RAII `ShardRecoveryGuard`
that removes the progress entry on drop, covering every exit path
including early returns and cancellation. The guard only removes its own
entry (checked via `Arc::ptr_eq`) so it never clobbers a newer recovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Extract recovery tracking into dedicated recovery_guard module

Move ShardRecoveryGuard out of the large shard_holder/mod.rs into a
dedicated recovery_guard.rs, and replace the verbose
`Arc<Mutex<HashMap<ShardId, Arc<Mutex<RecoveryProgress>>>>>` type with a
dedicated `ActiveRecoveries` struct that encapsulates start/comment/
set_stage/remove operations. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Bind recovery stage updates to the guard instance, not shard id

`ActiveRecoveries::remove` is pointer-guarded so an unwinding recovery
cannot delete a newer recovery's entry, but stage updates still resolved
the progress entry by shard id. If recovery A is still unwinding after
recovery B started for the same shard, A's late `set_stage` would mutate
B's progress and resurrect incorrect transfer comments.

Thread the guard's own progress handle (`RecoveryProgressHandle`) through
`recover_shard_snapshot_impl` -> `Collection::restore_shard_snapshot` ->
`ShardHolder::restore_shard_snapshot`, so the Unpacking/Restoring stages
(and Downloading, via `ShardRecoveryGuard::set_stage`) update that exact
recovery's progress. Direct API recoveries, which are not tracked as
transfer-side recoveries, pass `None`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Allow too_many_arguments on recover_shard_snapshot_impl

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrey Vasnetsov
2026-05-30 16:34:53 +02:00
committed by timvisee
parent 102c0dd3ed
commit 739a984da5
5 changed files with 136 additions and 38 deletions

View File

@@ -25,6 +25,7 @@ use crate::shards::remote_shard::RemoteShard;
use crate::shards::replica_set::ShardReplicaSet;
use crate::shards::shard::{PeerId, ShardId};
use crate::shards::shard_config::{self, ShardConfig};
use crate::shards::shard_holder::recovery_guard::RecoveryProgressHandle;
use crate::shards::shard_holder::shard_mapping::ShardKeyMapping;
use crate::shards::shard_holder::{SHARD_KEY_MAPPING_FILE, ShardHolder, shard_not_found_error};
use crate::shards::shard_path;
@@ -327,6 +328,7 @@ impl Collection {
this_peer_id: PeerId,
is_distributed: bool,
temp_dir: &Path,
recovery_progress: Option<RecoveryProgressHandle>,
cancel: cancel::CancellationToken,
) -> CollectionResult<impl Future<Output = CollectionResult<()>> + 'static> {
// `ShardHolder::validate_shard_snapshot` is cancel safe, so we explicitly cancel it
@@ -351,6 +353,7 @@ impl Collection {
this_peer_id,
is_distributed,
&temp_dir,
recovery_progress,
cancel,
)
.await?;

View File

@@ -1,3 +1,4 @@
pub mod recovery_guard;
mod resharding;
pub(crate) mod shard_mapping;
pub mod shared_shard_holder;
@@ -18,7 +19,6 @@ use fs_err as fs;
use fs_err::{File, tokio as tokio_fs};
use futures::{Future, StreamExt, TryStreamExt as _, stream};
use itertools::Itertools;
use parking_lot::Mutex;
use segment::json_path::JsonPath;
use segment::types::{PayloadFieldSchema, ShardKey, SnapshotFormat};
use segment::utils::fs::move_all;
@@ -34,7 +34,7 @@ pub use self::shared_shard_holder::*;
use super::replica_set::{AbortShardTransfer, ChangePeerFromState};
use super::resharding::{ReshardState, ReshardingStage};
use super::transfer::RecoveryStage;
use super::transfer::transfer_tasks_pool::{RecoveryProgress, TransferTasksPool};
use super::transfer::transfer_tasks_pool::TransferTasksPool;
use crate::collection::payload_index_schema::PayloadIndexSchema;
use crate::common::adaptive_handle::AdaptiveSearchHandle;
use crate::common::collection_size_stats::CollectionSizeStats;
@@ -55,6 +55,9 @@ use crate::shards::replica_set::ShardReplicaSet;
use crate::shards::replica_set::replica_set_state::ReplicaState;
use crate::shards::shard::{PeerId, ShardId};
use crate::shards::shard_config::ShardConfig;
use crate::shards::shard_holder::recovery_guard::{
ActiveRecoveries, RecoveryProgressHandle, ShardRecoveryGuard,
};
use crate::shards::transfer::{ShardTransfer, ShardTransferKey};
use crate::shards::{CollectionId, check_shard_path, shard_initializing_flag_path};
@@ -86,7 +89,8 @@ pub struct ShardHolder {
sharding_method: ShardingMethod,
/// Active snapshot recoveries on this peer (destination side of transfers).
/// Tracks progress of downloading, unpacking, and restoring snapshots.
active_recoveries: Mutex<HashMap<ShardId, Arc<Mutex<RecoveryProgress>>>>,
/// Entries are added and removed via [`ShardRecoveryGuard`].
active_recoveries: ActiveRecoveries,
}
impl ShardHolder {
@@ -129,7 +133,7 @@ impl ShardHolder {
key_mapping,
shard_id_to_key_mapping,
sharding_method,
active_recoveries: Mutex::new(HashMap::new()),
active_recoveries: ActiveRecoveries::default(),
})
}
@@ -476,18 +480,13 @@ impl ShardHolder {
(incoming, outgoing)
}
/// Start tracking recovery progress for a shard (destination side)
pub fn start_shard_recovery(&self, shard_id: ShardId) -> Arc<Mutex<RecoveryProgress>> {
let progress = Arc::new(Mutex::new(RecoveryProgress::new()));
self.active_recoveries
.lock()
.insert(shard_id, Arc::clone(&progress));
progress
}
/// Stop tracking recovery progress for a shard
pub fn finish_shard_recovery(&self, shard_id: ShardId) {
self.active_recoveries.lock().remove(&shard_id);
/// Start tracking recovery progress for a shard (destination side).
///
/// Returns a [`ShardRecoveryGuard`] that must be held for the duration of the
/// recovery. Dropping the guard - on success, error, or cancellation - stops
/// tracking and removes the progress entry.
pub fn start_shard_recovery(&self, shard_id: ShardId) -> ShardRecoveryGuard {
self.active_recoveries.start(shard_id)
}
pub fn get_shard_transfer_info(
@@ -505,13 +504,7 @@ impl ShardHolder {
// Check for active recovery on destination shard first, then sender task status
let target_shard = to_shard_id.unwrap_or(shard_id);
let recovery_comment = self
.active_recoveries
.lock()
.get(&target_shard)
.and_then(|p| p.lock().format_comment());
let comment = recovery_comment.or_else(|| {
let comment = self.active_recoveries.comment(target_shard).or_else(|| {
tasks_pool
.get_task_status(&shard_transfer.key())
.map(|p| p.comment)
@@ -1246,6 +1239,7 @@ impl ShardHolder {
this_peer_id: PeerId,
is_distributed: bool,
temp_dir: &Path,
recovery_progress: Option<RecoveryProgressHandle>,
cancel: cancel::CancellationToken,
) -> CollectionResult<()> {
if !self.contains_shard(shard_id) {
@@ -1261,8 +1255,8 @@ impl ShardHolder {
.tempdir_in(temp_dir)?;
// Set unpacking stage
if let Some(progress) = self.active_recoveries.lock().get(&shard_id) {
progress.lock().set_stage(RecoveryStage::Unpacking);
if let Some(recovery_progress) = &recovery_progress {
recovery_progress.lock().set_stage(RecoveryStage::Unpacking);
}
let extract = {
@@ -1303,8 +1297,8 @@ impl ShardHolder {
extract.await??;
// Set restoring stage
if let Some(progress) = self.active_recoveries.lock().get(&shard_id) {
progress.lock().set_stage(RecoveryStage::Restoring);
if let Some(recovery_progress) = &recovery_progress {
recovery_progress.lock().set_stage(RecoveryStage::Restoring);
}
// `ShardHolder::recover_local_shard_from` is *not* cancel safe

View File

@@ -0,0 +1,94 @@
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::Mutex;
use crate::shards::shard::ShardId;
use crate::shards::transfer::RecoveryStage;
use crate::shards::transfer::transfer_tasks_pool::RecoveryProgress;
/// Shared handle to a single recovery's progress.
///
/// Obtained from a [`ShardRecoveryGuard`] via [`ShardRecoveryGuard::progress_handle`]
/// and threaded into the recovery sub-steps so they update *this* recovery's progress
/// directly, rather than re-resolving the current map entry by shard id (which a newer
/// recovery for the same shard may have already replaced).
pub type RecoveryProgressHandle = Arc<Mutex<RecoveryProgress>>;
/// Tracks active snapshot recoveries on a peer (destination side of transfers),
/// keyed by shard id.
///
/// This is a cheaply cloneable handle around a shared map. Recoveries are started
/// via [`ActiveRecoveries::start`], which returns a [`ShardRecoveryGuard`] that
/// removes the entry again on drop.
#[derive(Clone, Default)]
pub struct ActiveRecoveries {
recoveries: Arc<Mutex<HashMap<ShardId, RecoveryProgressHandle>>>,
}
impl ActiveRecoveries {
/// Start tracking a recovery for `shard_id`, returning a guard that stops
/// tracking on drop.
pub fn start(&self, shard_id: ShardId) -> ShardRecoveryGuard {
let progress = Arc::new(Mutex::new(RecoveryProgress::new()));
self.recoveries
.lock()
.insert(shard_id, Arc::clone(&progress));
ShardRecoveryGuard {
active_recoveries: self.clone(),
shard_id,
progress,
}
}
/// Format the recovery progress comment for `shard_id`, if a recovery is active.
pub fn comment(&self, shard_id: ShardId) -> Option<String> {
self.recoveries
.lock()
.get(&shard_id)
.and_then(|progress| progress.lock().format_comment())
}
/// Remove the entry for `shard_id`, but only if it still points at `progress`,
/// so a newer recovery for the same shard is never clobbered.
fn remove(&self, shard_id: ShardId, progress: &RecoveryProgressHandle) {
let mut recoveries = self.recoveries.lock();
if let Some(current) = recoveries.get(&shard_id)
&& Arc::ptr_eq(current, progress)
{
recoveries.remove(&shard_id);
}
}
}
/// RAII guard for tracking an active snapshot recovery on the destination side.
///
/// While held, the recovery progress is registered in [`ActiveRecoveries`] so it can
/// be reported in the transfer status. On drop - including early returns, errors, and
/// cancellation - the entry is removed again, ensuring that stale recovery comments
/// are never reported for subsequent transfers.
pub struct ShardRecoveryGuard {
active_recoveries: ActiveRecoveries,
shard_id: ShardId,
progress: RecoveryProgressHandle,
}
impl ShardRecoveryGuard {
/// Update the recovery stage of *this* recovery.
pub fn set_stage(&self, stage: RecoveryStage) {
self.progress.lock().set_stage(stage);
}
/// A shared handle to this recovery's progress, for threading into recovery
/// sub-steps that need to update the stage. Bound to this guard's own entry, so
/// updates never leak into a newer recovery for the same shard.
pub fn progress_handle(&self) -> RecoveryProgressHandle {
Arc::clone(&self.progress)
}
}
impl Drop for ShardRecoveryGuard {
fn drop(&mut self) {
self.active_recoveries.remove(self.shard_id, &self.progress);
}
}

View File

@@ -560,6 +560,8 @@ async fn upload_shard_snapshot(
snapshot_data,
priority.unwrap_or_default(),
RecoveryType::Full,
// Direct API recovery is not tracked as a transfer-side recovery
None,
cancel,
)
.await?;
@@ -741,6 +743,8 @@ async fn recover_partial_snapshot(
snapshot_data,
priority.unwrap_or_default(),
RecoveryType::Partial,
// Direct API recovery is not tracked as a transfer-side recovery
None,
cancel,
)
.await?;
@@ -919,6 +923,8 @@ async fn recover_partial_snapshot_from(
snapshot_data,
SnapshotPriority::NoSync,
RecoveryType::Partial,
// Direct API recovery is not tracked as a transfer-side recovery
None,
cancel,
)
.await?;

View File

@@ -9,6 +9,7 @@ use collection::operations::snapshot_ops::{
use collection::operations::verification::VerificationPass;
use collection::shards::replica_set::replica_set_state::ReplicaState;
use collection::shards::shard::ShardId;
use collection::shards::shard_holder::recovery_guard::RecoveryProgressHandle;
use collection::shards::transfer::RecoveryStage;
use shard::snapshots::snapshot_data::SnapshotData;
use shard::snapshots::snapshot_manifest::{RecoveryType, SnapshotManifest};
@@ -187,8 +188,9 @@ pub async fn recover_shard_snapshot(
let (collection, download_dir) =
cancel::future::cancel_on_token(cancel.clone(), pre_recovery_task).await??;
// Once recovery tracking starts, `finish_shard_recovery` must run on all paths
let recovery_progress = collection
// Guard tracks recovery progress and removes it on drop, on every exit path
// (success, error, or cancellation), so stale timings are never reported.
let recovery_guard = collection
.shards_holder()
.read()
.await
@@ -220,9 +222,7 @@ pub async fn recover_shard_snapshot(
return Err(StorageError::bad_input(description));
}
recovery_progress
.lock()
.set_stage(RecoveryStage::Downloading);
recovery_guard.set_stage(RecoveryStage::Downloading);
let client = client.client(api_key.as_deref())?;
snapshots::download::download_snapshot(
@@ -293,16 +293,14 @@ pub async fn recover_shard_snapshot(
snapshot_data,
snapshot_priority,
RecoveryType::Full,
Some(recovery_guard.progress_handle()),
cancel,
)
.await;
// Finish tracking recovery progress
collection
.shards_holder()
.read()
.await
.finish_shard_recovery(shard_id);
// `recovery_guard` is dropped here (and on every early return above),
// which stops tracking recovery progress for this shard.
drop(recovery_guard);
result
})
@@ -314,6 +312,7 @@ pub async fn recover_shard_snapshot(
/// # Cancel safety
///
/// This function is *not* cancel safe.
#[allow(clippy::too_many_arguments)]
pub async fn recover_shard_snapshot_impl(
toc: &TableOfContent,
collection: &Collection,
@@ -321,6 +320,7 @@ pub async fn recover_shard_snapshot_impl(
snapshot_data: SnapshotData,
priority: SnapshotPriority,
recovery_type: RecoveryType,
recovery_progress: Option<RecoveryProgressHandle>,
cancel: cancel::CancellationToken,
) -> Result<(), StorageError> {
let _recover_tracker_guard = toc
@@ -343,6 +343,7 @@ pub async fn recover_shard_snapshot_impl(
toc.is_distributed(),
// Default temporary path to storage dir, to allow faster recovery within the same volume
&toc.optional_temp_or_storage_temp_path()?,
recovery_progress,
cancel,
)
.await?