Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/mqdb-agent/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 3 additions & 4 deletions crates/mqdb-agent/src/database/crud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/mqdb-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/mqdb-cluster/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/mqdb-cluster/src/cluster/db_handler/binary_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 0 additions & 10 deletions crates/mqdb-cluster/src/cluster/db_handler/helpers.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -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<u8> {
Expand Down
2 changes: 1 addition & 1 deletion crates/mqdb-cluster/src/cluster/db_handler/json_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 1 addition & 10 deletions crates/mqdb-cluster/src/cluster/node_controller/db_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1320,7 +1320,7 @@ impl<T: ClusterTransport> NodeController<T> {
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();
Expand Down Expand Up @@ -1900,15 +1900,6 @@ impl<T: ClusterTransport> NodeController<T> {
.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,
Expand Down
4 changes: 2 additions & 2 deletions crates/mqdb-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"]
Expand Down
52 changes: 31 additions & 21 deletions crates/mqdb-core/src/partition/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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");
}
}
2 changes: 1 addition & 1 deletion crates/mqdb-wasm/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading