From daa5ccd81fa2f2652b49dd7e33a4e6b21c5bba2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Bracht?= Date: Mon, 27 Jul 2026 10:13:43 -0300 Subject: [PATCH 1/3] narrow stale index-entry scan to indexed fields --- crates/mqdb-agent/src/database/query.rs | 76 ++++++++++++++++++++----- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/crates/mqdb-agent/src/database/query.rs b/crates/mqdb-agent/src/database/query.rs index 77efe1f1..419d26bc 100644 --- a/crates/mqdb-agent/src/database/query.rs +++ b/crates/mqdb-agent/src/database/query.rs @@ -247,7 +247,7 @@ impl Database { } } Err(Error::NotFound { .. }) => { - match self.purge_stale_index_entries(entity_name, id) { + match self.purge_stale_index_entries(entity_name, id).await { Ok(0) => tracing::debug!( entity = entity_name, id = %id, @@ -273,30 +273,26 @@ impl Database { Ok(results) } - fn purge_stale_index_entries(&self, entity_name: &str, id: &str) -> Result { + async fn purge_stale_index_entries(&self, entity_name: &str, id: &str) -> Result { let data_key = keys::encode_data_key(entity_name, id); if self.storage.get(&data_key)?.is_some() { return Ok(0); } - let mut entity_index_prefix = - Vec::with_capacity(keys::INDEX_PREFIX.len() + 1 + entity_name.len() + 1); - entity_index_prefix.extend_from_slice(keys::INDEX_PREFIX); - entity_index_prefix.push(keys::SEPARATOR); - entity_index_prefix.extend_from_slice(entity_name.as_bytes()); - entity_index_prefix.push(keys::SEPARATOR); - let mut id_suffix = Vec::with_capacity(1 + id.len()); id_suffix.push(keys::SEPARATOR); id_suffix.extend_from_slice(id.as_bytes()); - let candidate_keys = self.storage.prefix_scan_keys(&entity_index_prefix)?; + let scan_prefixes = self.stale_index_scan_prefixes(entity_name).await; + let mut batch = self.storage.batch(); let mut removed = 0usize; - for key in candidate_keys { - if key.ends_with(&id_suffix) { - batch.remove(key); - removed += 1; + for prefix in scan_prefixes { + for key in self.storage.prefix_scan_keys(&prefix)? { + if key.ends_with(&id_suffix) { + batch.remove(key); + removed += 1; + } } } if removed > 0 { @@ -305,6 +301,29 @@ impl Database { Ok(removed) } + /// The index key subtrees to scan when self-healing stale entries for an entity. + /// Narrows to each registered indexed field's `idx/{entity}/{field}/` subtree when + /// the index definition is known, falling back to the entity-wide `idx/{entity}/` + /// prefix for an entity with no registered index. + async fn stale_index_scan_prefixes(&self, entity_name: &str) -> Vec> { + let manager = self.index_manager.read().await; + match manager.get_indexed_fields(entity_name) { + Some(fields) if !fields.is_empty() => fields + .iter() + .map(|field| keys::encode_index_prefix(entity_name, field, Some(&[]))) + .collect(), + _ => { + let mut prefix = + Vec::with_capacity(keys::INDEX_PREFIX.len() + 1 + entity_name.len() + 1); + prefix.extend_from_slice(keys::INDEX_PREFIX); + prefix.push(keys::SEPARATOR); + prefix.extend_from_slice(entity_name.as_bytes()); + prefix.push(keys::SEPARATOR); + vec![prefix] + } + } + } + /// # Errors /// Returns an error if cursor initialization or field validation fails. pub async fn cursor( @@ -559,11 +578,38 @@ mod stale_index_tests { batch.insert(stray_key.clone(), Vec::new()); batch.commit().unwrap(); - let removed = db.purge_stale_index_entries("users", "ghost").unwrap(); + let removed = db + .purge_stale_index_entries("users", "ghost") + .await + .unwrap(); assert_eq!(removed, 0, "must not purge when data row still exists"); assert!( db.storage.get(&stray_key).unwrap().is_some(), "stray entry must remain when row exists (race-safe)", ); } + + #[tokio::test] + async fn purge_falls_back_to_entity_wide_scan_without_registered_index() { + let (_tmp, db) = test_db().await; + + let stray_value = keys::encode_value_for_index(&json!("ghost@x.com")).unwrap(); + let stray_key = keys::encode_index_key("users", "email", &stray_value, "ghost"); + let mut batch = db.storage.batch(); + batch.insert(stray_key.clone(), Vec::new()); + batch.commit().unwrap(); + + let removed = db + .purge_stale_index_entries("users", "ghost") + .await + .unwrap(); + assert_eq!( + removed, 1, + "entity-wide fallback must purge the stale entry" + ); + assert!( + db.storage.get(&stray_key).unwrap().is_none(), + "stale entry must be purged even with no registered index", + ); + } } From 1ac06bacdfa075277c3b93c969e909fd43d86912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Bracht?= Date: Wed, 29 Jul 2026 10:48:17 -0300 Subject: [PATCH 2/3] use uuid v7 for time-ordered server-generated ids --- CHANGELOG.md | 10 ++++ Cargo.lock | 8 +-- crates/mqdb-agent/Cargo.toml | 2 +- crates/mqdb-agent/src/database/crud.rs | 7 ++- crates/mqdb-cli/Cargo.toml | 2 +- crates/mqdb-cluster/Cargo.toml | 2 +- .../src/cluster/db_handler/binary_ops.rs | 2 +- .../src/cluster/db_handler/helpers.rs | 10 ---- .../src/cluster/db_handler/json_ops.rs | 2 +- .../src/cluster/node_controller/db_ops.rs | 11 +--- crates/mqdb-core/Cargo.toml | 4 +- crates/mqdb-core/src/partition/functions.rs | 52 +++++++++++-------- crates/mqdb-wasm/Cargo.lock | 2 +- 13 files changed, 57 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1943090..b34c4f1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. Each entry lists the date and the crate versions that were released. +## 2026-07-27 — mqdb-cli 0.8.22, mqdb-core 0.7.7, mqdb-agent 0.8.15, mqdb-cluster 0.4.6 + +### Changed + +- **Server-generated ids are now UUID v7 (time-ordered).** `generate_id_for_partition` used a `DefaultHasher` over entity/data/node/timestamp, producing ids that were not time-ordered — prefix scans over `data/{entity}/` returned records in hash order. The base is now a UUID v7 (48-bit millisecond timestamp prefix + random), so ids sort lexicographically by creation time and prefix scans return records in insertion order. The partition-targeting suffix loop is unchanged, so an id still maps to its intended partition. Existing hash-based ids remain valid; they simply do not sort chronologically alongside new ids. The `node_id`/`data` parameters are dropped from `generate_id_for_partition` (UUID v7 randomness supplies uniqueness), a breaking change to that `mqdb-core` function. + +### Fixed + +- **Stale index-entry self-heal scans only the indexed-field subtrees.** When a `list` read found an index entry pointing to a deleted row, `purge_stale_index_entries` scanned the entire `idx/{entity}/` prefix. It now scans only `idx/{entity}/{field}/` for each registered indexed field (falling back to the entity-wide prefix when no index is registered), shrinking the search space on entities with many indexed fields. Behaviour is unchanged; only the scanned key range is narrower. + ## 2026-07-26 — mqdb-cli 0.8.21, mqdb-agent 0.8.14 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d80e1cb1..c03b6ac9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "mqdb-agent" -version = "0.8.14" +version = "0.8.15" dependencies = [ "arc-swap", "argon2", @@ -1455,7 +1455,7 @@ dependencies = [ [[package]] name = "mqdb-cli" -version = "0.8.21" +version = "0.8.22" dependencies = [ "base64", "bebytes", @@ -1482,7 +1482,7 @@ dependencies = [ [[package]] name = "mqdb-cluster" -version = "0.4.5" +version = "0.4.6" dependencies = [ "arc-swap", "bebytes", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "mqdb-core" -version = "0.7.6" +version = "0.7.7" dependencies = [ "arc-swap", "bebytes", diff --git a/crates/mqdb-agent/Cargo.toml b/crates/mqdb-agent/Cargo.toml index f88e222a..91688731 100644 --- a/crates/mqdb-agent/Cargo.toml +++ b/crates/mqdb-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-agent" -version = "0.8.14" +version = "0.8.15" edition.workspace = true license = "Apache-2.0" authors.workspace = true diff --git a/crates/mqdb-agent/src/database/crud.rs b/crates/mqdb-agent/src/database/crud.rs index 799649f3..04e51158 100644 --- a/crates/mqdb-agent/src/database/crud.rs +++ b/crates/mqdb-agent/src/database/crud.rs @@ -53,8 +53,7 @@ impl Database { let id = if let Some(client_id) = data.get("id").and_then(Value::as_str) { client_id.to_string() } else { - let payload_bytes = serde_json::to_vec(&data).unwrap_or_default(); - let generated = Self::generate_id(&entity_name, &payload_bytes); + let generated = Self::generate_id(&entity_name); if let Value::Object(ref mut obj) = data { obj.insert("id".to_string(), Value::String(generated.clone())); } @@ -721,14 +720,14 @@ impl Database { .await } - pub(super) fn generate_id(entity_name: &str, data: &[u8]) -> String { + pub(super) fn generate_id(entity_name: &str) -> String { use std::sync::atomic::{AtomicU16, Ordering}; static COUNTER: AtomicU16 = AtomicU16::new(0); let idx = COUNTER.fetch_add(1, Ordering::Relaxed) % mqdb_core::partition::NUM_PARTITIONS; let partition = mqdb_core::partition::PartitionId::new(idx) .unwrap_or(mqdb_core::partition::PartitionId::ZERO); - mqdb_core::partition::generate_id_for_partition(1, entity_name, partition, data) + mqdb_core::partition::generate_id_for_partition(entity_name, partition) } } diff --git a/crates/mqdb-cli/Cargo.toml b/crates/mqdb-cli/Cargo.toml index d5c959fd..3d84f00b 100644 --- a/crates/mqdb-cli/Cargo.toml +++ b/crates/mqdb-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-cli" -version = "0.8.21" +version = "0.8.22" publish = false edition.workspace = true license = "AGPL-3.0-only" diff --git a/crates/mqdb-cluster/Cargo.toml b/crates/mqdb-cluster/Cargo.toml index 67858065..0fcdf3dd 100644 --- a/crates/mqdb-cluster/Cargo.toml +++ b/crates/mqdb-cluster/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-cluster" -version = "0.4.5" +version = "0.4.6" publish = false edition.workspace = true license = "AGPL-3.0-only" diff --git a/crates/mqdb-cluster/src/cluster/db_handler/binary_ops.rs b/crates/mqdb-cluster/src/cluster/db_handler/binary_ops.rs index ff0ea7b3..7bd38ff8 100644 --- a/crates/mqdb-cluster/src/cluster/db_handler/binary_ops.rs +++ b/crates/mqdb-cluster/src/cluster/db_handler/binary_ops.rs @@ -71,7 +71,7 @@ impl DbRequestHandler { return DbResponse::error(DbStatus::InvalidPartition).to_be_bytes(); } - let id = self.generate_id_for_partition(entity, partition, &request.data); + let id = crate::cluster::db::generate_id_for_partition(entity, partition); match controller .db_create(entity, &id, &request.data, request.timestamp_ms) diff --git a/crates/mqdb-cluster/src/cluster/db_handler/helpers.rs b/crates/mqdb-cluster/src/cluster/db_handler/helpers.rs index cecf0254..5c0a78ca 100644 --- a/crates/mqdb-cluster/src/cluster/db_handler/helpers.rs +++ b/crates/mqdb-cluster/src/cluster/db_handler/helpers.rs @@ -1,7 +1,6 @@ // Copyright 2025-2026 LabOverWire. All rights reserved. // SPDX-License-Identifier: AGPL-3.0-only -use super::super::PartitionId; use super::DbRequestHandler; use serde_json::{Value, json}; @@ -23,15 +22,6 @@ impl DbRequestHandler { ) .unwrap_or(u64::MAX) } - - pub(super) fn generate_id_for_partition( - &self, - entity: &str, - partition: PartitionId, - data: &[u8], - ) -> String { - super::super::db::generate_id_for_partition(self.node_id.get(), entity, partition, data) - } } pub(crate) fn json_ok_with_id(id: &str, data: &Value) -> Vec { diff --git a/crates/mqdb-cluster/src/cluster/db_handler/json_ops.rs b/crates/mqdb-cluster/src/cluster/db_handler/json_ops.rs index b1250166..c150d402 100644 --- a/crates/mqdb-cluster/src/cluster/db_handler/json_ops.rs +++ b/crates/mqdb-cluster/src/cluster/db_handler/json_ops.rs @@ -291,7 +291,7 @@ impl DbRequestHandler { (data_partition(entity, client_id), client_id.to_string()) } else { let p = controller.pick_partition_for_create(); - (p, self.generate_id_for_partition(entity, p, payload)) + (p, crate::cluster::db::generate_id_for_partition(entity, p)) }; let vault_crypto = self.resolve_vault_crypto(entity, sender); diff --git a/crates/mqdb-cluster/src/cluster/node_controller/db_ops.rs b/crates/mqdb-cluster/src/cluster/node_controller/db_ops.rs index fae270af..5c30ff4b 100644 --- a/crates/mqdb-cluster/src/cluster/node_controller/db_ops.rs +++ b/crates/mqdb-cluster/src/cluster/node_controller/db_ops.rs @@ -1320,7 +1320,7 @@ impl NodeController { let id = if let Some(client_id) = data.get("id").and_then(serde_json::Value::as_str) { client_id.to_string() } else { - self.generate_id_for_partition(entity, partition, payload) + crate::cluster::db::generate_id_for_partition(entity, partition) }; let request_id = uuid::Uuid::new_v4().to_string(); let now_ms = Self::current_time_ms(); @@ -1900,15 +1900,6 @@ impl NodeController { .unwrap_or(u64::MAX) } - fn generate_id_for_partition( - &self, - entity: &str, - partition: PartitionId, - data: &[u8], - ) -> String { - super::db::generate_id_for_partition(self.node_id.get(), entity, partition, data) - } - #[allow(clippy::too_many_arguments, clippy::cast_possible_truncation)] pub async fn forward_json_db_request( &mut self, diff --git a/crates/mqdb-core/Cargo.toml b/crates/mqdb-core/Cargo.toml index af97ed14..74421385 100644 --- a/crates/mqdb-core/Cargo.toml +++ b/crates/mqdb-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-core" -version = "0.7.6" +version = "0.7.7" edition.workspace = true license = "Apache-2.0" authors.workspace = true @@ -26,7 +26,7 @@ ring = { workspace = true, optional = true } tokio = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -uuid = { version = "1.18.1", features = ["v4", "js"] } +uuid = { version = "1.18.1", features = ["v4", "js", "v7"] } [features] default = ["fjall-backend", "native"] diff --git a/crates/mqdb-core/src/partition/functions.rs b/crates/mqdb-core/src/partition/functions.rs index e09dea0e..7e009014 100644 --- a/crates/mqdb-core/src/partition/functions.rs +++ b/crates/mqdb-core/src/partition/functions.rs @@ -47,35 +47,24 @@ pub fn schema_partition(entity: &str) -> PartitionId { PartitionId::new((hash % u32::from(NUM_PARTITIONS)) as u16).unwrap() } +/// Generate a server-side id that maps to `partition`. +/// +/// The base is a UUID v7 (48-bit millisecond timestamp prefix + random), so ids +/// are lexicographically time-ordered — prefix scans over `data/{entity}/` return +/// records in insertion order. The suffix loop preserves partition targeting: it +/// appends a 16-bit suffix until `data_partition(entity, id)` matches `partition`. #[must_use] -pub fn generate_id_for_partition( - node_id: u16, - entity: &str, - partition: PartitionId, - data: &[u8], -) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - entity.hash(&mut hasher); - data.hash(&mut hasher); - node_id.hash(&mut hasher); - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_nanos()) - .hash(&mut hasher); - - let base_id = hasher.finish(); +pub fn generate_id_for_partition(entity: &str, partition: PartitionId) -> String { + let base = uuid::Uuid::now_v7(); for suffix in 0..1000_u16 { - let id = format!("{base_id:016x}-{suffix:04x}"); + let id = format!("{base}-{suffix:04x}"); if data_partition(entity, &id) == partition { return id; } } - format!("{base_id:016x}-p{}", partition.get()) + format!("{base}-p{}", partition.get()) } #[cfg(test)] @@ -126,4 +115,25 @@ mod tests { assert!(p.get() < NUM_PARTITIONS); } } + + #[test] + fn generate_id_uses_time_ordered_uuid_v7_base() { + let partition = data_partition("users", "seed"); + let id = generate_id_for_partition("users", partition); + let base = id.rsplitn(2, '-').last().unwrap(); + let uuid = uuid::Uuid::parse_str(base).expect("id base must be a valid uuid"); + assert_eq!( + uuid.get_version_num(), + 7, + "id base must be a time-ordered uuid v7" + ); + } + + #[test] + fn generate_id_is_unique_across_calls() { + let partition = data_partition("users", "seed"); + let a = generate_id_for_partition("users", partition); + let b = generate_id_for_partition("users", partition); + assert_ne!(a, b, "generated ids must be unique"); + } } diff --git a/crates/mqdb-wasm/Cargo.lock b/crates/mqdb-wasm/Cargo.lock index 8a91ad0d..980a6836 100644 --- a/crates/mqdb-wasm/Cargo.lock +++ b/crates/mqdb-wasm/Cargo.lock @@ -342,7 +342,7 @@ dependencies = [ [[package]] name = "mqdb-core" -version = "0.7.6" +version = "0.7.7" dependencies = [ "arc-swap", "bebytes", From e17792e96e6734f6ab5031686a19d71cd35f00f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Bracht?= Date: Wed, 29 Jul 2026 21:26:05 -0300 Subject: [PATCH 3/3] drop narrowed stale-index scan; keep entity-wide self-heal --- CHANGELOG.md | 4 -- crates/mqdb-agent/src/database/query.rs | 76 +++++-------------------- 2 files changed, 15 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b34c4f1b..04f2d4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,6 @@ Each entry lists the date and the crate versions that were released. - **Server-generated ids are now UUID v7 (time-ordered).** `generate_id_for_partition` used a `DefaultHasher` over entity/data/node/timestamp, producing ids that were not time-ordered — prefix scans over `data/{entity}/` returned records in hash order. The base is now a UUID v7 (48-bit millisecond timestamp prefix + random), so ids sort lexicographically by creation time and prefix scans return records in insertion order. The partition-targeting suffix loop is unchanged, so an id still maps to its intended partition. Existing hash-based ids remain valid; they simply do not sort chronologically alongside new ids. The `node_id`/`data` parameters are dropped from `generate_id_for_partition` (UUID v7 randomness supplies uniqueness), a breaking change to that `mqdb-core` function. -### Fixed - -- **Stale index-entry self-heal scans only the indexed-field subtrees.** When a `list` read found an index entry pointing to a deleted row, `purge_stale_index_entries` scanned the entire `idx/{entity}/` prefix. It now scans only `idx/{entity}/{field}/` for each registered indexed field (falling back to the entity-wide prefix when no index is registered), shrinking the search space on entities with many indexed fields. Behaviour is unchanged; only the scanned key range is narrower. - ## 2026-07-26 — mqdb-cli 0.8.21, mqdb-agent 0.8.14 ### Fixed diff --git a/crates/mqdb-agent/src/database/query.rs b/crates/mqdb-agent/src/database/query.rs index 419d26bc..77efe1f1 100644 --- a/crates/mqdb-agent/src/database/query.rs +++ b/crates/mqdb-agent/src/database/query.rs @@ -247,7 +247,7 @@ impl Database { } } Err(Error::NotFound { .. }) => { - match self.purge_stale_index_entries(entity_name, id).await { + match self.purge_stale_index_entries(entity_name, id) { Ok(0) => tracing::debug!( entity = entity_name, id = %id, @@ -273,26 +273,30 @@ impl Database { Ok(results) } - async fn purge_stale_index_entries(&self, entity_name: &str, id: &str) -> Result { + fn purge_stale_index_entries(&self, entity_name: &str, id: &str) -> Result { let data_key = keys::encode_data_key(entity_name, id); if self.storage.get(&data_key)?.is_some() { return Ok(0); } + let mut entity_index_prefix = + Vec::with_capacity(keys::INDEX_PREFIX.len() + 1 + entity_name.len() + 1); + entity_index_prefix.extend_from_slice(keys::INDEX_PREFIX); + entity_index_prefix.push(keys::SEPARATOR); + entity_index_prefix.extend_from_slice(entity_name.as_bytes()); + entity_index_prefix.push(keys::SEPARATOR); + let mut id_suffix = Vec::with_capacity(1 + id.len()); id_suffix.push(keys::SEPARATOR); id_suffix.extend_from_slice(id.as_bytes()); - let scan_prefixes = self.stale_index_scan_prefixes(entity_name).await; - + let candidate_keys = self.storage.prefix_scan_keys(&entity_index_prefix)?; let mut batch = self.storage.batch(); let mut removed = 0usize; - for prefix in scan_prefixes { - for key in self.storage.prefix_scan_keys(&prefix)? { - if key.ends_with(&id_suffix) { - batch.remove(key); - removed += 1; - } + for key in candidate_keys { + if key.ends_with(&id_suffix) { + batch.remove(key); + removed += 1; } } if removed > 0 { @@ -301,29 +305,6 @@ impl Database { Ok(removed) } - /// The index key subtrees to scan when self-healing stale entries for an entity. - /// Narrows to each registered indexed field's `idx/{entity}/{field}/` subtree when - /// the index definition is known, falling back to the entity-wide `idx/{entity}/` - /// prefix for an entity with no registered index. - async fn stale_index_scan_prefixes(&self, entity_name: &str) -> Vec> { - let manager = self.index_manager.read().await; - match manager.get_indexed_fields(entity_name) { - Some(fields) if !fields.is_empty() => fields - .iter() - .map(|field| keys::encode_index_prefix(entity_name, field, Some(&[]))) - .collect(), - _ => { - let mut prefix = - Vec::with_capacity(keys::INDEX_PREFIX.len() + 1 + entity_name.len() + 1); - prefix.extend_from_slice(keys::INDEX_PREFIX); - prefix.push(keys::SEPARATOR); - prefix.extend_from_slice(entity_name.as_bytes()); - prefix.push(keys::SEPARATOR); - vec![prefix] - } - } - } - /// # Errors /// Returns an error if cursor initialization or field validation fails. pub async fn cursor( @@ -578,38 +559,11 @@ mod stale_index_tests { batch.insert(stray_key.clone(), Vec::new()); batch.commit().unwrap(); - let removed = db - .purge_stale_index_entries("users", "ghost") - .await - .unwrap(); + let removed = db.purge_stale_index_entries("users", "ghost").unwrap(); assert_eq!(removed, 0, "must not purge when data row still exists"); assert!( db.storage.get(&stray_key).unwrap().is_some(), "stray entry must remain when row exists (race-safe)", ); } - - #[tokio::test] - async fn purge_falls_back_to_entity_wide_scan_without_registered_index() { - let (_tmp, db) = test_db().await; - - let stray_value = keys::encode_value_for_index(&json!("ghost@x.com")).unwrap(); - let stray_key = keys::encode_index_key("users", "email", &stray_value, "ghost"); - let mut batch = db.storage.batch(); - batch.insert(stray_key.clone(), Vec::new()); - batch.commit().unwrap(); - - let removed = db - .purge_stale_index_entries("users", "ghost") - .await - .unwrap(); - assert_eq!( - removed, 1, - "entity-wide fallback must purge the stale entry" - ); - assert!( - db.storage.get(&stray_key).unwrap().is_none(), - "stale entry must be purged even with no registered index", - ); - } }