diff --git a/CHANGELOG.md b/CHANGELOG.md index c1943090..04f2d4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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. + ## 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",