mirror of
https://github.com/qdrant/qdrant.git
synced 2026-07-29 14:11:32 -05:00
Add reproducing test for spurious geo index warning on duplicate geo values (#8404)
* Add reproducing test for spurious geo index warning on duplicate geo values When a point has multiple geo values that produce the same max-precision geohash (e.g. duplicate coordinates in a multi-value geo field), `InMemoryGeoMapIndex::remove_point` logs a spurious warning: "Geo index error: no points for hash X was found". The root cause is an asymmetry between `point_to_values` (stores all values including duplicates) and `points_map` (uses a HashSet, so duplicates are collapsed). During removal the loop processes each value individually — the first iteration removes the `points_map` entry, and subsequent iterations for the same hash can't find it. This commit adds: - A `debug_assert!` in `remove_point` (matching the existing pattern in `decrement_hash_value_counts`) to make the issue detectable in tests. - `test_remove_point_with_duplicate_geo_values` — reproduces the bug by adding a point with `[BERLIN, BERLIN]` then removing it. - `test_frequent_add_remove_geo_points` — exercises repeated add/remove cycles to cover the user-reported scenario. Made-with: Cursor * fix: redulplicate hash IDs, replace warn with debug assertion * fmt --------- Co-authored-by: Cursor Agent <agent@cursor.com> Co-authored-by: generall <andrey@vasnetsov.com>
This commit is contained in:
@@ -1748,6 +1748,125 @@ mod tests {
|
||||
assert_eq!(point_offsets, vec![2]);
|
||||
}
|
||||
|
||||
/// Removing a point with duplicate geo values in a multi-value geo field
|
||||
/// must not produce spurious "no points for hash X was found" warnings.
|
||||
///
|
||||
/// The bug: `points_map` stores (hash → set of point IDs), so duplicate
|
||||
/// geo values for the same point are collapsed into one set entry.
|
||||
/// But `point_to_values` stores all values including duplicates.
|
||||
/// During `remove_point`, the loop iterates over each value individually.
|
||||
/// When two values share the same geohash, the first iteration removes the
|
||||
/// `points_map` entry, and the second iteration can't find it — triggering
|
||||
/// a spurious warning.
|
||||
#[rstest]
|
||||
#[cfg_attr(feature = "rocksdb", case(IndexType::Mutable))]
|
||||
#[case(IndexType::MutableGridstore)]
|
||||
fn test_remove_point_with_duplicate_geo_values(#[case] index_type: IndexType) {
|
||||
let (mut builder, _temp_dir, _db) = create_builder(index_type);
|
||||
let hw_counter = HardwareCounterCell::new();
|
||||
|
||||
// Point 0: multi-value geo payload with duplicate coordinates.
|
||||
// Both values produce the same max-precision geohash.
|
||||
let duplicate_geo = json!([
|
||||
{"lon": BERLIN.lon, "lat": BERLIN.lat},
|
||||
{"lon": BERLIN.lon, "lat": BERLIN.lat}
|
||||
]);
|
||||
builder
|
||||
.add_point(0, &[&duplicate_geo], &hw_counter)
|
||||
.unwrap();
|
||||
|
||||
// Point 1: single geo value (control, no duplicates)
|
||||
let single_geo = json!({"lon": NYC.lon, "lat": NYC.lat});
|
||||
builder.add_point(1, &[&single_geo], &hw_counter).unwrap();
|
||||
|
||||
let mut index = builder.finalize().unwrap();
|
||||
|
||||
assert_eq!(index.points_count(), 2);
|
||||
assert_eq!(index.points_values_count(), 3); // 2 duplicates + 1 single
|
||||
|
||||
// Removing the point with duplicate geo values must not trigger the
|
||||
// debug_assert / log::warn about missing points_map entries.
|
||||
index.remove_point(0).unwrap();
|
||||
|
||||
assert_eq!(index.points_count(), 1);
|
||||
assert_eq!(index.points_values_count(), 1);
|
||||
|
||||
// The remaining point should still be searchable
|
||||
let geo_radius = GeoRadius {
|
||||
center: NYC,
|
||||
radius: OrderedFloat(100.0),
|
||||
};
|
||||
let field_condition = condition_for_geo_radius("test", geo_radius);
|
||||
let hw_acc = HwMeasurementAcc::new();
|
||||
let hw_counter = hw_acc.get_counter_cell();
|
||||
let results = index
|
||||
.filter(&field_condition, &hw_counter)
|
||||
.unwrap()
|
||||
.collect_vec();
|
||||
assert_eq!(results, vec![1]);
|
||||
|
||||
// Remove the remaining point — index should be fully empty
|
||||
index.remove_point(1).unwrap();
|
||||
|
||||
assert_eq!(index.points_count(), 0);
|
||||
assert_eq!(index.points_values_count(), 0);
|
||||
}
|
||||
|
||||
/// Simulate the user's scenario: frequently adding and removing points
|
||||
/// with geo payloads. This exercises repeated add/remove cycles on the
|
||||
/// mutable geo index to check for index corruption.
|
||||
#[rstest]
|
||||
#[cfg_attr(feature = "rocksdb", case(IndexType::Mutable))]
|
||||
#[case(IndexType::MutableGridstore)]
|
||||
fn test_frequent_add_remove_geo_points(#[case] index_type: IndexType) {
|
||||
let (mut builder, _temp_dir, _db) = create_builder(index_type);
|
||||
let hw_counter = HardwareCounterCell::new();
|
||||
|
||||
// Start with a single point
|
||||
let berlin_geo = json!({"lon": BERLIN.lon, "lat": BERLIN.lat});
|
||||
builder.add_point(0, &[&berlin_geo], &hw_counter).unwrap();
|
||||
|
||||
let mut index = builder.finalize().unwrap();
|
||||
assert_eq!(index.points_count(), 1);
|
||||
|
||||
// Simulate repeated add/remove cycles
|
||||
for i in 1u32..20 {
|
||||
// Add a new point
|
||||
let geo = json!({"lon": NYC.lon, "lat": NYC.lat});
|
||||
index.add_point(i, &[&geo], &hw_counter).unwrap();
|
||||
assert_eq!(index.points_count(), 2);
|
||||
|
||||
// Remove it right away
|
||||
index.remove_point(i).unwrap();
|
||||
assert_eq!(index.points_count(), 1);
|
||||
}
|
||||
|
||||
// Update the remaining point's geo value (remove + add for same id)
|
||||
let tokyo_geo = json!({"lon": TOKYO.lon, "lat": TOKYO.lat});
|
||||
index.add_point(0, &[&tokyo_geo], &hw_counter).unwrap();
|
||||
assert_eq!(index.points_count(), 1);
|
||||
assert_eq!(index.points_values_count(), 1);
|
||||
|
||||
// Verify updated point is searchable at new location
|
||||
let geo_radius = GeoRadius {
|
||||
center: TOKYO,
|
||||
radius: OrderedFloat(100.0),
|
||||
};
|
||||
let field_condition = condition_for_geo_radius("test", geo_radius);
|
||||
let hw_acc = HwMeasurementAcc::new();
|
||||
let hw_counter = hw_acc.get_counter_cell();
|
||||
let results = index
|
||||
.filter(&field_condition, &hw_counter)
|
||||
.unwrap()
|
||||
.collect_vec();
|
||||
assert_eq!(results, vec![0]);
|
||||
|
||||
// Clean up
|
||||
index.remove_point(0).unwrap();
|
||||
assert_eq!(index.points_count(), 0);
|
||||
assert_eq!(index.points_values_count(), 0);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[cfg_attr(feature = "rocksdb", case(&[IndexType::Mutable, IndexType::MutableGridstore, IndexType::Immutable, IndexType::Mmap, IndexType::RamMmap], false))]
|
||||
#[cfg_attr(feature = "rocksdb", case(&[IndexType::Mutable, IndexType::MutableGridstore, IndexType::Immutable, IndexType::RamMmap], true))]
|
||||
|
||||
@@ -487,20 +487,27 @@ impl InMemoryGeoMapIndex {
|
||||
|
||||
self.points_count -= 1;
|
||||
self.points_values_count -= removed_geo_points.len();
|
||||
let mut removed_geo_hashes = Vec::with_capacity(removed_geo_points.len());
|
||||
let mut removed_geo_hashes = AHashSet::with_capacity(removed_geo_points.len());
|
||||
|
||||
for removed_geo_point in removed_geo_points {
|
||||
let removed_geo_hash: GeoHash =
|
||||
encode_max_precision(removed_geo_point.lon.0, removed_geo_point.lat.0).map_err(
|
||||
|e| OperationError::service_error(format!("Malformed geo points: {e}")),
|
||||
)?;
|
||||
removed_geo_hashes.push(removed_geo_hash);
|
||||
let already_seen = removed_geo_hashes.insert(removed_geo_hash);
|
||||
|
||||
if already_seen {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_last = if let Some(hash_ids) = self.points_map.get_mut(&removed_geo_hash) {
|
||||
hash_ids.remove(&idx);
|
||||
hash_ids.is_empty()
|
||||
} else {
|
||||
log::warn!("Geo index error: no points for hash {removed_geo_hash} was found");
|
||||
debug_assert!(
|
||||
false,
|
||||
"Geo index error: no points for hash {removed_geo_hash} was found",
|
||||
);
|
||||
false
|
||||
};
|
||||
|
||||
@@ -511,7 +518,7 @@ impl InMemoryGeoMapIndex {
|
||||
self.decrement_hash_value_counts(removed_geo_hash);
|
||||
}
|
||||
|
||||
self.decrement_hash_point_counts(&removed_geo_hashes);
|
||||
self.decrement_hash_point_counts(removed_geo_hashes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -628,7 +635,7 @@ impl InMemoryGeoMapIndex {
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement_hash_point_counts(&mut self, geo_hashes: &[GeoHash]) {
|
||||
fn decrement_hash_point_counts(&mut self, geo_hashes: impl IntoIterator<Item = GeoHash>) {
|
||||
let mut seen_hashes: AHashSet<GeoHash> = Default::default();
|
||||
for geo_hash in geo_hashes {
|
||||
for i in 0..=geo_hash.len() {
|
||||
|
||||
Reference in New Issue
Block a user