Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{cleanup::store_destroy, server::TestServerBuilder};
|
||||
use ahash::AHashMap;
|
||||
use email::message::metadata::MessageMetadata;
|
||||
use registry::{
|
||||
schema::{enums::CompressionAlgo, structs::Jmap},
|
||||
types::duration::Duration,
|
||||
};
|
||||
use services::task_manager::destroy_account::destroy_account_blobs;
|
||||
use store::{
|
||||
BlobStore, Serialize, SerializeInfallible,
|
||||
write::{Archiver, BatchBuilder, BlobLink, BlobOp, ValueClass, now},
|
||||
};
|
||||
use types::{blob::BlobClass, blob_hash::BlobHash, collection::Collection, field::EmailField};
|
||||
|
||||
#[tokio::test]
|
||||
pub async fn blob_tests() {
|
||||
let test = TestServerBuilder::new("blob_tests")
|
||||
.await
|
||||
.with_object(Jmap {
|
||||
upload_quota: 1024,
|
||||
upload_ttl: Duration::from_millis(1000),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let store = test.server.core.storage.data.clone();
|
||||
let blob_store = test.server.core.storage.blob.clone();
|
||||
|
||||
println!(
|
||||
"Testing blob store {} with data store {}...",
|
||||
std::env::var("BLOB_STORE").unwrap_or_else(|_| "default".to_string()),
|
||||
std::env::var("STORE").unwrap()
|
||||
);
|
||||
|
||||
// Test blob quota
|
||||
assert!(test.server.blob_has_quota(0, 1024).await.unwrap().allowed);
|
||||
assert!(!test.server.blob_has_quota(0, 1024).await.unwrap().allowed);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
assert!(test.server.blob_has_quota(0, 1024).await.unwrap().allowed);
|
||||
|
||||
// Test and reset store
|
||||
test_store(blob_store.clone()).await;
|
||||
store_destroy(&store).await;
|
||||
|
||||
// Blob hash exists
|
||||
let hash = BlobHash::generate(b"abc".as_slice());
|
||||
assert!(!store.blob_exists(&hash).await.unwrap());
|
||||
|
||||
// Reserve blob
|
||||
let until = now() + 1;
|
||||
store
|
||||
.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.set(
|
||||
BlobOp::Link {
|
||||
to: BlobLink::Temporary { until },
|
||||
hash: hash.clone(),
|
||||
},
|
||||
1024u32.serialize(),
|
||||
)
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Uncommitted blob, should not exist
|
||||
assert!(!store.blob_exists(&hash).await.unwrap());
|
||||
|
||||
// Write blob to store
|
||||
blob_store
|
||||
.put_blob(hash.as_ref(), b"abc", CompressionAlgo::Lz4)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Commit blob
|
||||
store
|
||||
.write(
|
||||
BatchBuilder::new()
|
||||
.set(BlobOp::Commit { hash: hash.clone() }, Vec::new())
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Blob hash should now exist
|
||||
assert!(store.blob_exists(&hash).await.unwrap());
|
||||
assert!(
|
||||
blob_store
|
||||
.get_blob(hash.as_ref(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// AccountId 0 should be able to read blob
|
||||
assert!(
|
||||
store
|
||||
.blob_has_access(
|
||||
&hash,
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: until
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// AccountId 1 should not be able to read blob
|
||||
assert!(
|
||||
!store
|
||||
.blob_has_access(
|
||||
&hash,
|
||||
BlobClass::Reserved {
|
||||
account_id: 1,
|
||||
expires: until
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// Purge expired blobs
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
store
|
||||
.purge_blobs_all_shards(blob_store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Blob hash should no longer exist
|
||||
assert!(!store.blob_exists(&hash).await.unwrap());
|
||||
|
||||
// AccountId 0 should not be able to read blob
|
||||
assert!(
|
||||
!store
|
||||
.blob_has_access(
|
||||
&hash,
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: until
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// Blob should no longer be in store
|
||||
assert!(
|
||||
blob_store
|
||||
.get_blob(hash.as_ref(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Upload one linked blob to accountId 1, two linked blobs to accountId 0, and three unlinked (reserved) blobs to accountId 2
|
||||
let expiry_times = AHashMap::from_iter([
|
||||
(b"abc", now() - 10),
|
||||
(b"efg", now() + 10),
|
||||
(b"hij", now() + 10),
|
||||
]);
|
||||
for (document_id, (blob, _)) in [
|
||||
(b"123", vec![]),
|
||||
(b"456", vec![]),
|
||||
(b"789", vec![]),
|
||||
(b"abc", 5000u32.serialize()),
|
||||
(b"efg", 1000u32.serialize()),
|
||||
(b"hij", 2000u32.serialize()),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let hash = BlobHash::generate(blob.as_slice());
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(if document_id > 0 { 0 } else { 1 })
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(document_id as u32);
|
||||
if let Some(until) = expiry_times.get(blob) {
|
||||
batch.set(
|
||||
BlobOp::Link {
|
||||
hash: hash.clone(),
|
||||
to: BlobLink::Temporary { until: *until },
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
} else {
|
||||
batch
|
||||
.set(
|
||||
BlobOp::Link {
|
||||
hash: hash.clone(),
|
||||
to: BlobLink::Document,
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.set(
|
||||
ValueClass::Property(EmailField::Metadata.into()),
|
||||
Archiver::new(MessageMetadata {
|
||||
contents: Default::default(),
|
||||
rcvd_attach: Default::default(),
|
||||
blob_hash: hash.clone(),
|
||||
blob_body_offset: Default::default(),
|
||||
preview: Default::default(),
|
||||
raw_headers: Default::default(),
|
||||
})
|
||||
.serialize()
|
||||
.unwrap(),
|
||||
);
|
||||
};
|
||||
batch.set(BlobOp::Commit { hash: hash.clone() }, vec![]);
|
||||
|
||||
store.write(batch.build_all()).await.unwrap();
|
||||
blob_store
|
||||
.put_blob(hash.as_ref(), blob.as_slice(), CompressionAlgo::Lz4)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Purge expired blobs and make sure nothing else is deleted
|
||||
store
|
||||
.purge_blobs_all_shards(blob_store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
for (pos, (blob, blob_class)) in [
|
||||
(
|
||||
b"abc",
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: expiry_times[&b"abc"],
|
||||
},
|
||||
),
|
||||
(
|
||||
b"123",
|
||||
BlobClass::Linked {
|
||||
account_id: 1,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
b"456",
|
||||
BlobClass::Linked {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 1,
|
||||
},
|
||||
),
|
||||
(
|
||||
b"789",
|
||||
BlobClass::Linked {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 2,
|
||||
},
|
||||
),
|
||||
(
|
||||
b"efg",
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: expiry_times[&b"efg"],
|
||||
},
|
||||
),
|
||||
(
|
||||
b"hij",
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: expiry_times[&b"hij"],
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let hash = BlobHash::generate(blob.as_slice());
|
||||
let ct = pos == 0;
|
||||
assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct);
|
||||
assert!(store.blob_exists(&hash).await.unwrap() ^ ct);
|
||||
assert!(
|
||||
blob_store
|
||||
.get_blob(hash.as_ref(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
^ ct
|
||||
);
|
||||
}
|
||||
|
||||
// AccountId 0 should not have access to accountId 1's blobs
|
||||
assert!(
|
||||
!store
|
||||
.blob_has_access(
|
||||
BlobHash::generate(b"123".as_slice()),
|
||||
BlobClass::Linked {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// Unlink blob
|
||||
store
|
||||
.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(2)
|
||||
.clear(BlobOp::Link {
|
||||
hash: BlobHash::generate(b"789".as_slice()),
|
||||
to: BlobLink::Document,
|
||||
})
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Purge and make sure blob is deleted
|
||||
store
|
||||
.purge_blobs_all_shards(blob_store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
for (pos, (blob, blob_class)) in [
|
||||
(
|
||||
b"789",
|
||||
BlobClass::Linked {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 2,
|
||||
},
|
||||
),
|
||||
(
|
||||
b"123",
|
||||
BlobClass::Linked {
|
||||
account_id: 1,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
b"456",
|
||||
BlobClass::Linked {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 1,
|
||||
},
|
||||
),
|
||||
(
|
||||
b"efg",
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: expiry_times[&b"efg"],
|
||||
},
|
||||
),
|
||||
(
|
||||
b"hij",
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: expiry_times[&b"hij"],
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let ct = pos == 0;
|
||||
let hash = BlobHash::generate(blob.as_slice());
|
||||
assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct);
|
||||
assert!(store.blob_exists(&hash).await.unwrap() ^ ct);
|
||||
assert!(
|
||||
blob_store
|
||||
.get_blob(hash.as_ref(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
^ ct
|
||||
);
|
||||
}
|
||||
|
||||
// Unlink all blobs from accountId 1 and purge
|
||||
destroy_account_blobs(&test.server, 1).await.unwrap();
|
||||
store
|
||||
.purge_blobs_all_shards(blob_store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Make sure only accountId 0's blobs are left
|
||||
for (pos, (blob, blob_class)) in [
|
||||
(
|
||||
b"123",
|
||||
BlobClass::Linked {
|
||||
account_id: 1,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
b"456",
|
||||
BlobClass::Linked {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 1,
|
||||
},
|
||||
),
|
||||
(
|
||||
b"efg",
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: expiry_times[&b"efg"],
|
||||
},
|
||||
),
|
||||
(
|
||||
b"hij",
|
||||
BlobClass::Reserved {
|
||||
account_id: 0,
|
||||
expires: expiry_times[&b"hij"],
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let ct = pos == 0;
|
||||
let hash = BlobHash::generate(blob.as_slice());
|
||||
assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct);
|
||||
assert!(store.blob_exists(&hash).await.unwrap() ^ ct);
|
||||
assert!(
|
||||
blob_store
|
||||
.get_blob(hash.as_ref(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
^ ct
|
||||
);
|
||||
}
|
||||
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
|
||||
async fn test_store(store: BlobStore) {
|
||||
// Test small blob
|
||||
const DATA: &[u8] = b"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce erat nisl, dignissim a porttitor id, varius nec arcu. Sed mauris.";
|
||||
let hash = BlobHash::generate(DATA);
|
||||
|
||||
store
|
||||
.put_blob(hash.as_slice(), DATA, CompressionAlgo::Lz4)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(
|
||||
store
|
||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
std::str::from_utf8(DATA).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(
|
||||
store
|
||||
.get_blob(hash.as_slice(), 11..57)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
std::str::from_utf8(&DATA[11..57]).unwrap()
|
||||
);
|
||||
assert!(store.delete_blob(hash.as_slice()).await.unwrap());
|
||||
assert!(
|
||||
store
|
||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Test large blob
|
||||
let mut data = Vec::with_capacity(50 * 1024 * 1024);
|
||||
while data.len() < 50 * 1024 * 1024 {
|
||||
data.extend_from_slice(DATA);
|
||||
let marker = format!(" [{}] ", data.len());
|
||||
data.extend_from_slice(marker.as_bytes());
|
||||
}
|
||||
let hash = BlobHash::generate(&data);
|
||||
store
|
||||
.put_blob(hash.as_slice(), &data, CompressionAlgo::Lz4)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(
|
||||
store
|
||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
std::str::from_utf8(&data).unwrap()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(
|
||||
store
|
||||
.get_blob(hash.as_slice(), 3000111..4000999)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
std::str::from_utf8(&data[3000111..4000999]).unwrap()
|
||||
);
|
||||
assert!(store.delete_blob(hash.as_slice()).await.unwrap());
|
||||
assert!(
|
||||
store
|
||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{
|
||||
cleanup::{store_assert_is_empty, store_destroy},
|
||||
server::TestServer,
|
||||
temp_dir::TempDir,
|
||||
};
|
||||
use ::registry::schema::enums::CompressionAlgo;
|
||||
use ahash::AHashSet;
|
||||
use common::{DATABASE_SCHEMA_VERSION, manager::backup::BackupParams};
|
||||
use store::{
|
||||
rand,
|
||||
write::{
|
||||
AnyClass, AnyKey, BatchBuilder, BlobLink, BlobOp, Operation, QueueClass, QueueEvent,
|
||||
RegistryClass, ValueClass, key::KeySerializer,
|
||||
},
|
||||
*,
|
||||
};
|
||||
use types::{
|
||||
blob_hash::BlobHash,
|
||||
collection::{Collection, SyncCollection},
|
||||
field::{Field, MailboxField},
|
||||
};
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
// Make sure the store is empty
|
||||
store_assert_is_empty(test.server.store(), test.server.blob_store().clone(), true).await;
|
||||
let db = test.server.store().clone();
|
||||
|
||||
// Create blobs
|
||||
println!("Creating blobs...");
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_PROPERTY,
|
||||
key: vec![0u8],
|
||||
}),
|
||||
DATABASE_SCHEMA_VERSION.serialize(),
|
||||
);
|
||||
let mut blob_hashes = Vec::new();
|
||||
for blob_size in [16, 128, 1024, 2056, 102400] {
|
||||
let data = random_bytes(blob_size);
|
||||
let hash = BlobHash::generate(data.as_slice());
|
||||
blob_hashes.push(hash.clone());
|
||||
test.server
|
||||
.blob_store()
|
||||
.put_blob(hash.as_ref(), &data, CompressionAlgo::Lz4)
|
||||
.await
|
||||
.unwrap();
|
||||
batch.set(ValueClass::Blob(BlobOp::Commit { hash }), vec![]);
|
||||
}
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
|
||||
// Create account data
|
||||
println!("Creating account data...");
|
||||
for account_id in 0u32..10u32 {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.with_account_id(account_id);
|
||||
|
||||
// Create properties of different sizes
|
||||
for collection in [
|
||||
Collection::Email,
|
||||
Collection::Mailbox,
|
||||
Collection::Thread,
|
||||
Collection::Identity,
|
||||
] {
|
||||
batch.with_collection(collection);
|
||||
|
||||
for document_id in [0, 10, 20, 30, 40] {
|
||||
batch.with_document(document_id);
|
||||
|
||||
if collection == Collection::Mailbox {
|
||||
batch
|
||||
.set(
|
||||
ValueClass::Property(Field::ARCHIVE.into()),
|
||||
random_bytes(10),
|
||||
)
|
||||
.add(
|
||||
ValueClass::Property(MailboxField::UidCounter.into()),
|
||||
rand::random(),
|
||||
);
|
||||
}
|
||||
|
||||
for (idx, value_size) in [16, 128, 1024, 2056, 102400].into_iter().enumerate() {
|
||||
batch.set(ValueClass::Property(idx as u8), random_bytes(value_size));
|
||||
}
|
||||
|
||||
for grant_account_id in 0u32..10u32 {
|
||||
if account_id != grant_account_id {
|
||||
batch.set(
|
||||
ValueClass::Acl(grant_account_id),
|
||||
vec![account_id as u8, grant_account_id as u8, document_id as u8],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for hash in &blob_hashes {
|
||||
batch.set(
|
||||
ValueClass::Blob(BlobOp::Link {
|
||||
hash: hash.clone(),
|
||||
to: BlobLink::Document,
|
||||
}),
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
|
||||
batch.log_item_insert(SyncCollection::from(collection), None);
|
||||
|
||||
for field in 0..5 {
|
||||
batch.any_op(Operation::Index {
|
||||
field,
|
||||
key: random_bytes(field as usize + 2),
|
||||
set: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
}
|
||||
|
||||
// Create queue, config and lookup data
|
||||
println!("Creating queue, config and lookup data...");
|
||||
let mut batch = BatchBuilder::new();
|
||||
for idx in [1, 2, 3, 4, 5] {
|
||||
batch.set(
|
||||
ValueClass::Queue(QueueClass::Message(rand::random())),
|
||||
random_bytes(idx),
|
||||
);
|
||||
batch.set(
|
||||
ValueClass::Queue(QueueClass::MessageEvent(QueueEvent {
|
||||
due: rand::random(),
|
||||
queue_id: rand::random(),
|
||||
queue_name: rand::random(),
|
||||
})),
|
||||
random_bytes(idx),
|
||||
);
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 0,
|
||||
item_id: 1,
|
||||
}),
|
||||
random_bytes(idx + 10),
|
||||
);
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::IndexId {
|
||||
object_id: idx as u16,
|
||||
item_id: idx as u64 * 100,
|
||||
}),
|
||||
vec![],
|
||||
);
|
||||
for index_id in 0u16..3 {
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::Index {
|
||||
object_id: idx as u16,
|
||||
index_id,
|
||||
key: random_bytes(idx + index_id as usize),
|
||||
item_id: idx as u64 * 100,
|
||||
}),
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
}
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
|
||||
// Create directory data
|
||||
println!("Creating directory data...");
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(u32::MAX)
|
||||
.with_collection(Collection::Principal);
|
||||
|
||||
for account_id in [1, 2, 3, 4, 5] {
|
||||
batch
|
||||
.with_document(account_id)
|
||||
.add(ValueClass::Quota, account_id as i64 * 1000);
|
||||
}
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
|
||||
// Obtain store hash
|
||||
println!("Calculating store hash...");
|
||||
let snapshot = Snapshot::new(&db).await;
|
||||
assert!(!snapshot.keys.is_empty(), "Store hash counts are empty",);
|
||||
|
||||
// Export store
|
||||
println!("Exporting store...");
|
||||
let temp_dir = TempDir::new("art_vandelay_tests", true);
|
||||
test.server
|
||||
.core
|
||||
.backup(BackupParams::new(temp_dir.path.clone()))
|
||||
.await;
|
||||
|
||||
// Destroy store
|
||||
println!("Destroying store...");
|
||||
store_destroy(&db).await;
|
||||
store_assert_is_empty(&db, db.clone().into(), true).await;
|
||||
|
||||
// Import store over a node id lease
|
||||
println!("Importing store...");
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(
|
||||
ValueClass::NodeId(0),
|
||||
KeySerializer::new(U64_LEN + 9)
|
||||
.write(0u64)
|
||||
.write("localhost")
|
||||
.finalize(),
|
||||
);
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
test.server.core.restore(temp_dir.path.clone()).await;
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(ValueClass::NodeId(0));
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
|
||||
// Verify hash
|
||||
print!("Verifying store hash...");
|
||||
snapshot.assert_is_eq(&Snapshot::new(&db).await);
|
||||
println!(" GREAT SUCCESS!");
|
||||
|
||||
// Destroy store
|
||||
store_destroy(&db).await;
|
||||
store_assert_is_empty(&db, db.clone().into(), true).await;
|
||||
temp_dir.delete();
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Snapshot {
|
||||
keys: AHashSet<KeyValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
struct KeyValue {
|
||||
subspace: u8,
|
||||
key: Vec<u8>,
|
||||
value: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
async fn new(db: &Store) -> Self {
|
||||
let is_sql = db.is_sql();
|
||||
|
||||
let mut keys = AHashSet::new();
|
||||
|
||||
for (subspace, with_values) in [
|
||||
(SUBSPACE_ACL, true),
|
||||
(SUBSPACE_TASK_QUEUE, true),
|
||||
(SUBSPACE_INDEXES, false),
|
||||
(SUBSPACE_DELETED_ITEMS, true),
|
||||
(SUBSPACE_SPAM_SAMPLES, true),
|
||||
(SUBSPACE_BLOB_LINK, true),
|
||||
(SUBSPACE_BLOBS, true),
|
||||
(SUBSPACE_LOGS, true),
|
||||
(SUBSPACE_COUNTER, !is_sql),
|
||||
(SUBSPACE_IN_MEMORY_COUNTER, !is_sql),
|
||||
(SUBSPACE_IN_MEMORY_VALUE, true),
|
||||
(SUBSPACE_PROPERTY, true),
|
||||
(SUBSPACE_REGISTRY, true),
|
||||
(SUBSPACE_REGISTRY_IDX, !is_sql),
|
||||
(SUBSPACE_REGISTRY_PK, true),
|
||||
(SUBSPACE_QUEUE_MESSAGE, true),
|
||||
(SUBSPACE_QUEUE_EVENT, true),
|
||||
(SUBSPACE_QUOTA, !is_sql),
|
||||
(SUBSPACE_REPORT_OUT, true),
|
||||
(SUBSPACE_REPORT_IN, true),
|
||||
] {
|
||||
let from_key = AnyKey {
|
||||
subspace,
|
||||
key: vec![0u8],
|
||||
};
|
||||
let to_key = AnyKey {
|
||||
subspace,
|
||||
key: vec![u8::MAX; 10],
|
||||
};
|
||||
|
||||
db.iterate(
|
||||
IterateParams::new(from_key, to_key).set_values(with_values),
|
||||
|key, value| {
|
||||
keys.insert(KeyValue {
|
||||
subspace,
|
||||
key: key.to_vec(),
|
||||
value: value.to_vec(),
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
Snapshot { keys }
|
||||
}
|
||||
|
||||
fn assert_is_eq(&self, other: &Self) {
|
||||
let mut is_err = false;
|
||||
for key in &self.keys {
|
||||
if !other.keys.contains(key) {
|
||||
println!(
|
||||
"Subspace {}, Key {:?} not found in restored snapshot",
|
||||
char::from(key.subspace),
|
||||
key.key,
|
||||
);
|
||||
is_err = true;
|
||||
}
|
||||
}
|
||||
for key in &other.keys {
|
||||
if !self.keys.contains(key) {
|
||||
println!(
|
||||
"Subspace {}, Key {:?} not found in original snapshot",
|
||||
char::from(key.subspace),
|
||||
key.key,
|
||||
);
|
||||
is_err = true;
|
||||
}
|
||||
}
|
||||
|
||||
if is_err {
|
||||
panic!("Snapshot mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random_bytes(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|_| rand::random::<u8>()).collect()
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{
|
||||
cleanup::{store_assert_is_empty, store_destroy},
|
||||
server::TestServerBuilder,
|
||||
};
|
||||
use registry::schema::structs::Rate;
|
||||
use registry::types::duration::Duration;
|
||||
use store::{InMemoryStore, dispatch::lookup::KeyValue};
|
||||
|
||||
#[tokio::test]
|
||||
pub async fn lookup_tests() {
|
||||
let test = TestServerBuilder::new("lookup_tests").await.build().await;
|
||||
let store = test.server.in_memory_store().clone();
|
||||
let rate = Rate {
|
||||
count: 1,
|
||||
period: Duration::from_millis(1000),
|
||||
};
|
||||
|
||||
println!(
|
||||
"Testing in-memory store {}...",
|
||||
std::env::var("MEMORY_STORE").unwrap_or_else(|_| "default".to_string())
|
||||
);
|
||||
if let InMemoryStore::Store(store) = &store {
|
||||
store_destroy(store).await;
|
||||
} else {
|
||||
// Reset redis counter
|
||||
store
|
||||
.key_set(KeyValue::new("abc", "0".as_bytes().to_vec()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Test key
|
||||
let key = "xyz".as_bytes().to_vec();
|
||||
store
|
||||
.key_set(KeyValue::new(key.clone(), "world".to_string().into_bytes()))
|
||||
.await
|
||||
.unwrap();
|
||||
store.purge_in_memory_store().await.unwrap();
|
||||
assert_eq!(
|
||||
store.key_get::<String>(key.clone()).await.unwrap(),
|
||||
Some("world".to_string())
|
||||
);
|
||||
|
||||
// Test value expiry
|
||||
store
|
||||
.key_set(KeyValue::new(key.clone(), "hello".to_string().into_bytes()).expires(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.key_get::<String>(key.clone()).await.unwrap(),
|
||||
Some("hello".to_string())
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
assert_eq!(None, store.key_get::<String>(key.clone()).await.unwrap());
|
||||
|
||||
store.purge_in_memory_store().await.unwrap();
|
||||
if let InMemoryStore::Store(store) = &store {
|
||||
store_assert_is_empty(store, store.clone().into(), false).await;
|
||||
}
|
||||
|
||||
// Test counter
|
||||
let key = "abc".as_bytes().to_vec();
|
||||
store
|
||||
.counter_incr(KeyValue::new(key.clone(), 1), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(1, store.counter_get(key.clone()).await.unwrap());
|
||||
store
|
||||
.counter_incr(KeyValue::new(key.clone(), 2), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(3, store.counter_get(key.clone()).await.unwrap());
|
||||
store
|
||||
.counter_incr(KeyValue::new(key.clone(), -3), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(0, store.counter_get(key.clone()).await.unwrap());
|
||||
|
||||
// Test counter expiry
|
||||
let key = "fgh".as_bytes().to_vec();
|
||||
store
|
||||
.counter_incr(KeyValue::new(key.clone(), 1).expires(1), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(1, store.counter_get(key.clone()).await.unwrap());
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
store.purge_in_memory_store().await.unwrap();
|
||||
assert_eq!(0, store.counter_get(key.clone()).await.unwrap());
|
||||
|
||||
// Test rate limiter
|
||||
assert!(
|
||||
store
|
||||
.is_rate_allowed(0, "rate".as_bytes(), &rate, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.is_rate_allowed(0, "rate".as_bytes(), &rate, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
assert!(
|
||||
store
|
||||
.is_rate_allowed(0, "rate".as_bytes(), &rate, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
store.purge_in_memory_store().await.unwrap();
|
||||
if let InMemoryStore::Store(store) = &store {
|
||||
store_assert_is_empty(store, store.clone().into(), false).await;
|
||||
}
|
||||
|
||||
// Test locking
|
||||
for iteration in [1, 2] {
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..100 {
|
||||
let store = store.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
store.try_lock(0, "lock".as_bytes(), 1).await.unwrap()
|
||||
}));
|
||||
}
|
||||
// Only one should return true
|
||||
let mut count = 0;
|
||||
for task in tasks {
|
||||
if task.await.unwrap() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(1, count, "Iteration {}", iteration);
|
||||
|
||||
// Wait 2 seconds for the lock to expire
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
store.purge_in_memory_store().await.unwrap();
|
||||
|
||||
// Failed lock attempts must not extend the lock expiry
|
||||
assert!(store.try_lock(0, "lock".as_bytes(), 2).await.unwrap());
|
||||
let mut acquired = false;
|
||||
for _ in 0..12 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
if store.try_lock(0, "lock".as_bytes(), 2).await.unwrap() {
|
||||
acquired = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(acquired, "Abandoned lock was never released");
|
||||
store.remove_lock(0, "lock".as_bytes()).await.unwrap();
|
||||
store.purge_in_memory_store().await.unwrap();
|
||||
if let InMemoryStore::Store(store) = &store {
|
||||
store_assert_is_empty(store, store.clone().into(), false).await;
|
||||
}
|
||||
|
||||
// Test prefix delete
|
||||
store
|
||||
.key_set(KeyValue::with_prefix(
|
||||
1,
|
||||
[0],
|
||||
"hello".to_string().into_bytes(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
for v in 0u32..2020u32 {
|
||||
store
|
||||
.key_set(KeyValue::with_prefix(
|
||||
0,
|
||||
pack_u32(0, v),
|
||||
"world".to_string().into_bytes(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.counter_incr(
|
||||
KeyValue::with_prefix(0, pack_u32(1, v), 123).expires(3600),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Make sure the keys are there
|
||||
assert_eq!(
|
||||
Some("hello"),
|
||||
store
|
||||
.key_get::<String>(KeyValue::<()>::build_key(1, [0]))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
);
|
||||
for v in [0, 1000, 1001, 2000, 2001] {
|
||||
assert_eq!(
|
||||
Some("world"),
|
||||
store
|
||||
.key_get::<String>(KeyValue::<()>::build_key(0, pack_u32(0, v)))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
);
|
||||
}
|
||||
for v in [0, 1000, 1001, 2000, 2001] {
|
||||
assert_ne!(
|
||||
0,
|
||||
store
|
||||
.counter_get(KeyValue::<()>::build_key(0, pack_u32(1, v)))
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// Delete [0, 0, 0, 0, 1] prefix and make sure only the keys with that prefix are gone
|
||||
store
|
||||
.key_delete_prefix(&KeyValue::<()>::build_key(0, 1u32.to_be_bytes()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
Some("hello"),
|
||||
store
|
||||
.key_get::<String>(KeyValue::<()>::build_key(1, [0]))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
);
|
||||
for v in [0, 1000, 1001, 2000, 2001] {
|
||||
assert_eq!(
|
||||
Some("world"),
|
||||
store
|
||||
.key_get::<String>(KeyValue::<()>::build_key(0, pack_u32(0, v)))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
);
|
||||
}
|
||||
|
||||
for v in [0, 1000, 1001, 2000, 2001] {
|
||||
assert_eq!(
|
||||
0,
|
||||
store
|
||||
.counter_get(KeyValue::<()>::build_key(0, pack_u32(1, v)))
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// Delete [0, 0, 0, 0, 0] prefix and make sure only the keys with that prefix are gone
|
||||
store
|
||||
.key_delete_prefix(&KeyValue::<()>::build_key(0, 0u32.to_be_bytes()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
Some("hello"),
|
||||
store
|
||||
.key_get::<String>(KeyValue::<()>::build_key(1, [0]))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
);
|
||||
for v in [0, 1000, 1001, 2000, 2001] {
|
||||
assert_eq!(
|
||||
None,
|
||||
store
|
||||
.key_get::<String>(KeyValue::<()>::build_key(0, pack_u32(0, v)))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
);
|
||||
}
|
||||
|
||||
// Delete [1, ...] prefix and make sure it's all gone
|
||||
store.key_delete_prefix(&[1u8]).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
None,
|
||||
store
|
||||
.key_get::<String>(KeyValue::<()>::build_key(1, [0]))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref()
|
||||
);
|
||||
|
||||
if let InMemoryStore::Store(store) = &store {
|
||||
store_assert_is_empty(store, store.clone().into(), false).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_u32(a: u32, b: u32) -> Vec<u8> {
|
||||
(((a as u64) << 32) | b as u64).to_be_bytes().to_vec()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod blob;
|
||||
pub mod import_export;
|
||||
pub mod lookup;
|
||||
pub mod ops;
|
||||
pub mod query;
|
||||
pub mod registry;
|
||||
#[cfg(any(feature = "postgres", feature = "mysql"))]
|
||||
pub mod sql_timeout;
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
use std::io::Read;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn store_tests() {
|
||||
let test = TestServerBuilder::new("store_tests").await.build().await;
|
||||
|
||||
println!("Testing store {}...", std::env::var("STORE").unwrap());
|
||||
|
||||
test.destroy_store().await;
|
||||
|
||||
registry::test(&test).await;
|
||||
import_export::test(&test).await;
|
||||
ops::test(&test).await;
|
||||
#[cfg(any(feature = "postgres", feature = "mysql"))]
|
||||
sql_timeout::test(&test).await;
|
||||
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn search_tests() {
|
||||
let test = TestServerBuilder::new("search_store_tests")
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
|
||||
println!(
|
||||
"Testing search store {}...",
|
||||
std::env::var("SEARCH_STORE").unwrap_or("default".to_string())
|
||||
);
|
||||
|
||||
query::test(&test).await;
|
||||
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deflate_test_resource(name: &str) -> Vec<u8> {
|
||||
let mut csv_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
csv_path.push("resources");
|
||||
csv_path.push(name);
|
||||
|
||||
let mut decoder = flate2::bufread::GzDecoder::new(std::io::BufReader::new(
|
||||
std::fs::File::open(csv_path).unwrap(),
|
||||
));
|
||||
let mut result = Vec::new();
|
||||
decoder.read_to_end(&mut result).unwrap();
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{cleanup::store_assert_is_empty, server::TestServer};
|
||||
use ahash::AHashSet;
|
||||
use std::collections::HashSet;
|
||||
use store::{
|
||||
ValueKey,
|
||||
rand::{self, RngExt},
|
||||
write::{AlignedBytes, Archive, Archiver, BatchBuilder, MergeResult, Params, ValueClass},
|
||||
};
|
||||
use types::collection::Collection;
|
||||
use types::collection::SyncCollection;
|
||||
|
||||
// FDB max value
|
||||
const MAX_VALUE_SIZE: usize = 100000;
|
||||
|
||||
#[cfg(feature = "foundationdb")]
|
||||
fn value_gen(chunks: impl IntoIterator<Item = (u8, usize)>) -> Vec<u8> {
|
||||
let mut value = Vec::new();
|
||||
for (byte, size) in chunks {
|
||||
value.extend(std::iter::repeat_n(byte, size));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let db = test.server.store().clone();
|
||||
|
||||
#[cfg(feature = "foundationdb")]
|
||||
if matches!(db, store::Store::FoundationDb(_)) {
|
||||
use store::write::RegistryClass;
|
||||
println!("Running FoundationDB chunked iterator test...");
|
||||
let kvs = [
|
||||
(1, value_gen([(b'a', 1)])),
|
||||
(2, value_gen([(b'b', MAX_VALUE_SIZE), (b'0', 1)])),
|
||||
(
|
||||
3,
|
||||
value_gen([
|
||||
(b'c', MAX_VALUE_SIZE),
|
||||
(b'1', MAX_VALUE_SIZE),
|
||||
(b'2', MAX_VALUE_SIZE),
|
||||
]),
|
||||
),
|
||||
(
|
||||
4,
|
||||
value_gen([(b'd', MAX_VALUE_SIZE), (b'3', MAX_VALUE_SIZE)]),
|
||||
),
|
||||
(5, value_gen([(b'e', 1)])),
|
||||
];
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0);
|
||||
|
||||
for (key, value) in &kvs {
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: *key,
|
||||
item_id: 0,
|
||||
}),
|
||||
value.clone(),
|
||||
);
|
||||
}
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
|
||||
// Iterate over all keys
|
||||
let mut results = Vec::new();
|
||||
db.iterate(
|
||||
store::IterateParams::new(
|
||||
ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 0,
|
||||
item_id: 0,
|
||||
}),
|
||||
},
|
||||
ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: u16::MAX,
|
||||
item_id: u64::MAX,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|key, value| {
|
||||
results.push((String::from_utf8(key.to_vec()).unwrap(), value.to_vec()));
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), kvs.len());
|
||||
|
||||
db.delete_range(
|
||||
ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 0,
|
||||
item_id: 0,
|
||||
}),
|
||||
},
|
||||
ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: u16::MAX,
|
||||
item_id: u64::MAX,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Read-your-writes through the cached read version: overwrite a key in a tight loop
|
||||
println!("Running FoundationDB read-your-writes test...");
|
||||
for n in 0u64..200 {
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0)
|
||||
.set(
|
||||
ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 100,
|
||||
item_id: 0,
|
||||
}),
|
||||
n.to_be_bytes().to_vec(),
|
||||
)
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got = db
|
||||
.get_value::<u64>(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 100,
|
||||
item_id: 0,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(got, n, "stale read: wrote {n} but read back {got}");
|
||||
}
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0)
|
||||
.clear(ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 100,
|
||||
item_id: 0,
|
||||
}))
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Read-version cache monotonicity under concurrency: while a writer increments a counter
|
||||
println!("Running FoundationDB read-version monotonicity test...");
|
||||
let n_increments = 500u64;
|
||||
|
||||
let writer = {
|
||||
let db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..n_increments {
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(5000)
|
||||
.add_and_get(ValueClass::Quota, 1)
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let mut readers = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let db = db.clone();
|
||||
readers.push(tokio::spawn(async move {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500);
|
||||
let mut last = 0i64;
|
||||
while std::time::Instant::now() < deadline {
|
||||
let current = db
|
||||
.get_counter(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 5000,
|
||||
class: ValueClass::Quota,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
current >= last,
|
||||
"read version regressed: counter went from {last} to {current}"
|
||||
);
|
||||
last = current;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
writer.await.unwrap();
|
||||
for reader in readers {
|
||||
reader.await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
db.get_counter(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 5000,
|
||||
class: ValueClass::Quota,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
n_increments as i64,
|
||||
"counter did not reach the expected total"
|
||||
);
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(5000)
|
||||
.clear(ValueClass::Quota)
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Overwriting a chunked value with a shorter one must not leave orphaned chunks behind
|
||||
println!("Running FoundationDB orphaned chunk test...");
|
||||
const ORPHAN_FIELD: u8 = 200;
|
||||
let orphan_range = |document_id: u32| ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id,
|
||||
class: ValueClass::Property(ORPHAN_FIELD),
|
||||
};
|
||||
let marker = value_gen([(b'z', 16)]);
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(1)
|
||||
.set(ValueClass::Property(ORPHAN_FIELD), marker.clone())
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (byte, size) in [
|
||||
(b'a', MAX_VALUE_SIZE * 3),
|
||||
(b'b', MAX_VALUE_SIZE * 2),
|
||||
(b'c', MAX_VALUE_SIZE / 2),
|
||||
(b'd', MAX_VALUE_SIZE * 3 / 2),
|
||||
(b'e', MAX_VALUE_SIZE),
|
||||
(b'f', 1),
|
||||
(b'g', MAX_VALUE_SIZE * 4),
|
||||
(b'h', 0),
|
||||
] {
|
||||
let value = value_gen([(byte, size)]);
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0)
|
||||
.set(ValueClass::Property(ORPHAN_FIELD), value.clone())
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut results = Vec::new();
|
||||
db.iterate(
|
||||
store::IterateParams::new(orphan_range(0), orphan_range(u32::MAX)),
|
||||
|key, value| {
|
||||
results.push((key.to_vec(), value.to_vec()));
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
results.len(),
|
||||
2,
|
||||
"orphaned chunks surfaced as extra rows after writing {size} bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
results[0].1.len(),
|
||||
value.len(),
|
||||
"stale chunk spliced onto the value after writing {size} bytes"
|
||||
);
|
||||
assert_eq!(results[0].1, value, "value mismatch for {size} bytes");
|
||||
assert_eq!(
|
||||
results[1].1, marker,
|
||||
"neighbouring document corrupted after writing {size} bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
results[0].0.len(),
|
||||
results[1].0.len(),
|
||||
"chunk key returned as a document key after writing {size} bytes"
|
||||
);
|
||||
}
|
||||
|
||||
// A short key sharing a subspace with longer structured keys must never clear them,
|
||||
// as the database schema version does in the property subspace
|
||||
println!("Running FoundationDB short key test...");
|
||||
for document_id in [0u32, 1, 0xFFFF, 0x10000] {
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(document_id)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(document_id)
|
||||
.set(ValueClass::Property(ORPHAN_FIELD), marker.clone())
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.set(
|
||||
ValueClass::Any(store::write::AnyClass {
|
||||
subspace: store::SUBSPACE_PROPERTY,
|
||||
key: vec![0u8],
|
||||
}),
|
||||
vec![1u8],
|
||||
)
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for document_id in [0u32, 1, 0xFFFF, 0x10000] {
|
||||
let key = ValueKey {
|
||||
account_id: document_id,
|
||||
collection: 0,
|
||||
document_id,
|
||||
class: ValueClass::Property(ORPHAN_FIELD),
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
db.iterate(
|
||||
store::IterateParams::new(key.clone(), key.clone()),
|
||||
|_, value| {
|
||||
found.push(value.to_vec());
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![marker.clone()],
|
||||
"property key for account {document_id} was cleared by a shorter key"
|
||||
);
|
||||
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(document_id)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(document_id)
|
||||
.clear(ValueClass::Property(ORPHAN_FIELD))
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.clear(ValueClass::Any(store::write::AnyClass {
|
||||
subspace: store::SUBSPACE_PROPERTY,
|
||||
key: vec![0u8],
|
||||
}))
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.delete_range(orphan_range(0), orphan_range(u32::MAX))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if std::env::var("SLOW_FDB_TRX").is_ok() {
|
||||
println!("Running FoundationDB slow transaction tests...");
|
||||
// Create 900000 keys
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0);
|
||||
for n in 0..900000 {
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 0,
|
||||
item_id: n,
|
||||
}),
|
||||
format!("value{n:10}").into_bytes(),
|
||||
);
|
||||
|
||||
if n % 10000 == 0 {
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0);
|
||||
}
|
||||
}
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
|
||||
println!("Created 900.000 keys...");
|
||||
|
||||
// Iterate over all keys
|
||||
let mut n = 0;
|
||||
db.iterate(
|
||||
store::IterateParams::new(
|
||||
ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 0,
|
||||
item_id: 0,
|
||||
}),
|
||||
},
|
||||
ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 0,
|
||||
item_id: u64::MAX,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|key, value| {
|
||||
assert_eq!(std::str::from_utf8(key).unwrap(), format!("key{n:10}"));
|
||||
assert_eq!(std::str::from_utf8(value).unwrap(), format!("value{n:10}"));
|
||||
n += 1;
|
||||
if n % 10000 == 0 {
|
||||
println!("Iterated over {n} keys");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Delete 100 keys
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0);
|
||||
for n in 0..900000 {
|
||||
batch.clear(ValueClass::Registry(RegistryClass::Item {
|
||||
object_id: 0,
|
||||
item_id: n,
|
||||
}));
|
||||
|
||||
if n % 10000 == 0 {
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0);
|
||||
}
|
||||
}
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Merge values 1000 times concurrently
|
||||
let mut handles = Vec::new();
|
||||
println!("Merge values 1000 times concurrently...");
|
||||
for _ in 0..1000 {
|
||||
handles.push({
|
||||
let db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..5 {
|
||||
let mut builder = BatchBuilder::new();
|
||||
builder
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0)
|
||||
.merge_fnc(
|
||||
ValueClass::Property(3),
|
||||
Params::with_capacity(0),
|
||||
|_, _, bytes| {
|
||||
if let Some(bytes) = bytes {
|
||||
Ok(MergeResult::Update(
|
||||
(u64::from_be_bytes(bytes.try_into().unwrap()) + 1)
|
||||
.to_be_bytes()
|
||||
.to_vec(),
|
||||
))
|
||||
} else {
|
||||
Ok(MergeResult::Update(0u64.to_be_bytes().to_vec()))
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
match db.write(builder.build_all()).await {
|
||||
Ok(_) => {
|
||||
break;
|
||||
}
|
||||
Err(e) if e.is_assertion_failure() => {
|
||||
// Retry on assertion failures
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Merge failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
999,
|
||||
db.get_value::<u64>(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Property(3),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// Increment a counter 1000 times concurrently
|
||||
let mut handles = Vec::new();
|
||||
let mut assigned_ids = HashSet::new();
|
||||
println!("Incrementing counter 1000 times concurrently...");
|
||||
for _ in 0..1000 {
|
||||
handles.push({
|
||||
let db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut builder = BatchBuilder::new();
|
||||
builder
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0)
|
||||
.add_and_get(ValueClass::Quota, 1);
|
||||
db.write(builder.build_all())
|
||||
.await
|
||||
.unwrap()
|
||||
.last_counter_id()
|
||||
.unwrap()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
let assigned_id = handle.await.unwrap();
|
||||
assert!(
|
||||
assigned_ids.insert(assigned_id),
|
||||
"counter assigned {assigned_id} twice or more times."
|
||||
);
|
||||
}
|
||||
assert_eq!(assigned_ids.len(), 1000);
|
||||
assert_eq!(
|
||||
db.get_counter(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Quota,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
1000
|
||||
);
|
||||
|
||||
// Concurrent changelog
|
||||
let mut handles = Vec::new();
|
||||
let mut assigned_ids = AHashSet::new();
|
||||
print!("Incrementing changeId 1000 times concurrently...");
|
||||
let time = std::time::Instant::now();
|
||||
for document_id in 0..1000 {
|
||||
handles.push({
|
||||
let db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut builder = BatchBuilder::new();
|
||||
let value = if document_id != 0 {
|
||||
(0..rand::rng().random_range(1..=100))
|
||||
.map(|_| rand::rng().random_range(0..=255))
|
||||
.collect::<Vec<u8>>()
|
||||
} else {
|
||||
vec![0u8; 100000]
|
||||
};
|
||||
|
||||
let (offset, archived_value) = Archiver::new(value).serialize_versioned().unwrap();
|
||||
|
||||
builder
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(document_id)
|
||||
.set_fnc(
|
||||
ValueClass::Property(5),
|
||||
Params::with_capacity(2)
|
||||
.with_bytes(archived_value)
|
||||
.with_u64(offset),
|
||||
|params, ids| {
|
||||
let change_id = ids.current_change_id()?;
|
||||
let archive = params.bytes(0);
|
||||
let offset = params.u64(1);
|
||||
|
||||
let mut bytes = Vec::with_capacity(archive.len());
|
||||
bytes.extend_from_slice(&archive[..offset as usize]);
|
||||
bytes.extend_from_slice(&change_id.to_be_bytes()[..]);
|
||||
bytes.push(archive.last().copied().unwrap()); // Marker
|
||||
Ok(bytes)
|
||||
},
|
||||
)
|
||||
.log_container_insert(SyncCollection::Email);
|
||||
db.write(builder.build_all())
|
||||
.await
|
||||
.unwrap()
|
||||
.last_change_id(0)
|
||||
.unwrap()
|
||||
})
|
||||
});
|
||||
}
|
||||
for handle in handles {
|
||||
let assigned_id = handle.await.unwrap();
|
||||
assert!(
|
||||
assigned_ids.insert(assigned_id),
|
||||
"counter assigned {assigned_id} twice or more times: {:?}.",
|
||||
assigned_ids
|
||||
);
|
||||
}
|
||||
assert_eq!(assigned_ids.len(), 1000);
|
||||
println!(" done in {:?}ms", time.elapsed().as_millis());
|
||||
let mut change_ids = AHashSet::new();
|
||||
for document_id in 0..1000 {
|
||||
let archive = db
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id,
|
||||
class: ValueClass::Property(5),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
change_ids.insert(archive.version.change_id().unwrap());
|
||||
archive.unarchive_untrusted::<Vec<u8>>().unwrap();
|
||||
}
|
||||
assert_eq!(change_ids, assigned_ids);
|
||||
|
||||
println!("Running chunking tests...");
|
||||
for (test_num, value) in [
|
||||
vec![b'A'; 0],
|
||||
vec![b'A'; 1],
|
||||
vec![b'A'; 100],
|
||||
vec![b'A'; MAX_VALUE_SIZE],
|
||||
vec![b'B'; MAX_VALUE_SIZE + 1],
|
||||
vec![b'C'; MAX_VALUE_SIZE]
|
||||
.into_iter()
|
||||
.chain(vec![b'D'; MAX_VALUE_SIZE])
|
||||
.chain(vec![b'E'; MAX_VALUE_SIZE])
|
||||
.collect::<Vec<_>>(),
|
||||
vec![b'F'; MAX_VALUE_SIZE]
|
||||
.into_iter()
|
||||
.chain(vec![b'G'; MAX_VALUE_SIZE])
|
||||
.chain(vec![b'H'; MAX_VALUE_SIZE + 1])
|
||||
.collect::<Vec<_>>(),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
// Write value
|
||||
let test_len = value.len();
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0)
|
||||
.set(ValueClass::Property(1), value.as_slice())
|
||||
.set(ValueClass::Property(0), "check1".as_bytes())
|
||||
.set(ValueClass::Property(2), "check2".as_bytes())
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fetch value
|
||||
assert_eq!(
|
||||
String::from_utf8(value).unwrap(),
|
||||
db.get_value::<String>(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Property(1),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("no value for test {test_num} with value length {test_len}")),
|
||||
"failed for test {test_num} with value length {test_len}"
|
||||
);
|
||||
|
||||
// Delete value
|
||||
db.write(
|
||||
BatchBuilder::new()
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(0)
|
||||
.clear(ValueClass::Property(1))
|
||||
.build_all(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Make sure value is deleted
|
||||
assert_eq!(
|
||||
None,
|
||||
db.get_value::<String>(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Property(1),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// Make sure other values are still there
|
||||
for (class, value) in [
|
||||
(ValueClass::Property(0), "check1"),
|
||||
(ValueClass::Property(2), "check2"),
|
||||
] {
|
||||
assert_eq!(
|
||||
Some(value.to_string()),
|
||||
db.get_value::<String>(ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// Delete everything
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(0)
|
||||
.with_collection(Collection::Email)
|
||||
.with_account_id(0)
|
||||
.with_document(0)
|
||||
.clear(ValueClass::Property(0))
|
||||
.clear(ValueClass::Property(2))
|
||||
.clear(ValueClass::Property(3))
|
||||
.clear(ValueClass::Quota)
|
||||
.clear(ValueClass::ChangeId);
|
||||
|
||||
for document_id in 0..1000 {
|
||||
batch
|
||||
.with_document(document_id)
|
||||
.clear(ValueClass::Property(5));
|
||||
}
|
||||
|
||||
db.write(batch.build_all()).await.unwrap();
|
||||
|
||||
// Make sure everything is deleted
|
||||
store_assert_is_empty(&db, db.clone().into(), false).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{store::deflate_test_resource, utils::server::TestServer};
|
||||
use ahash::AHashSet;
|
||||
use nlp::language::Language;
|
||||
use std::{
|
||||
io::Write,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
use store::{
|
||||
SearchStore,
|
||||
ahash::AHashMap,
|
||||
rand::{self, RngExt, distr::Alphanumeric},
|
||||
roaring::RoaringBitmap,
|
||||
search::{
|
||||
EmailSearchField, IndexDocument, SearchComparator, SearchField, SearchFilter,
|
||||
SearchOperator, SearchQuery, SearchValue, TracingSearchField,
|
||||
},
|
||||
write::SearchIndex,
|
||||
};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub const FIELDS: [&str; 20] = [
|
||||
"id",
|
||||
"accession_number",
|
||||
"artist",
|
||||
"artistRole",
|
||||
"artistId",
|
||||
"title",
|
||||
"dateText",
|
||||
"medium",
|
||||
"creditLine",
|
||||
"year",
|
||||
"acquisitionYear",
|
||||
"dimensions",
|
||||
"width",
|
||||
"height",
|
||||
"depth",
|
||||
"units",
|
||||
"inscription",
|
||||
"thumbnailCopyright",
|
||||
"thumbnailUrl",
|
||||
"url",
|
||||
];
|
||||
|
||||
/*
|
||||
"title", // Subject
|
||||
"year". // ReceivedAt
|
||||
"width", // Size
|
||||
"height", // SentAt
|
||||
"artist" // Headers
|
||||
"artistRole" // Cc
|
||||
"medium", // From
|
||||
"creditLine" // Body
|
||||
"acquisitionYear" // Bcc
|
||||
"accession_number" // To
|
||||
*/
|
||||
|
||||
const FIELD_MAPPINGS: [EmailSearchField; 20] = [
|
||||
EmailSearchField::HasAttachment, // "id",
|
||||
EmailSearchField::To, // "accession_number",
|
||||
EmailSearchField::Headers, // "artist",
|
||||
EmailSearchField::Cc, // "artistRole",
|
||||
EmailSearchField::HasAttachment, // "artistId",
|
||||
EmailSearchField::Subject, // "title",
|
||||
EmailSearchField::HasAttachment, // "dateText",
|
||||
EmailSearchField::From, // "medium",
|
||||
EmailSearchField::Body, // "creditLine",
|
||||
EmailSearchField::ReceivedAt, // "year",
|
||||
EmailSearchField::Bcc, // "acquisitionYear",
|
||||
EmailSearchField::HasAttachment, // "dimensions",
|
||||
EmailSearchField::Size, // "width",
|
||||
EmailSearchField::SentAt, // "height",
|
||||
EmailSearchField::HasAttachment, // "depth",
|
||||
EmailSearchField::HasAttachment, // "units",
|
||||
EmailSearchField::HasAttachment, // "inscription",
|
||||
EmailSearchField::HasAttachment, // "thumbnailCopyright",
|
||||
EmailSearchField::HasAttachment, // "thumbnailUrl",
|
||||
EmailSearchField::HasAttachment, // "url",
|
||||
];
|
||||
|
||||
const ALL_IDS: &[&str] = &[
|
||||
"p11293", "p79426", "p79427", "p79428", "p79429", "p79430", "d05503", "d00399", "d05352",
|
||||
"p01764", "t05843", "n02478", "n02479", "n03568", "n03658", "n04327", "n04328", "n04721",
|
||||
"n04739", "n05095", "n05096", "n05145", "n05157", "n05158", "n05159", "n05298", "n05303",
|
||||
"n06070", "t01181", "t03571", "t05805", "t05806", "t12147", "t12154", "t12155", "ar00039",
|
||||
"t12600", "p80203", "t13209", "t13560", "t13561", "t13655", "t13811", "p13352", "p13351",
|
||||
"p13350", "p13349", "p13348", "p13347", "p13346", "p13345", "p13344", "p13342", "p13341",
|
||||
"p13340", "p13339", "p13338", "p13337", "p13336", "p13335", "p13334", "p13333", "p13332",
|
||||
"p13331", "p13330", "p13329", "p13328", "p13327", "p13326", "p13325", "p13324", "p13323",
|
||||
"t13786", "p13322", "p13321", "p13320", "p13319", "p13318", "p13317", "p13316", "p13315",
|
||||
"p13314", "t13588", "t13587", "t13586", "t13585", "t13584", "t13540", "t13444", "ar01154",
|
||||
"ar01153", "t03681", "t12601", "ar00166", "t12625", "t12915", "p04182", "t06483", "ar00703",
|
||||
"t07671", "ar00021", "t05557", "t07918", "p06298", "p05465", "p06640", "t12855", "t01355",
|
||||
"t12800", "t12557", "t02078", "ar00052", "ar00627", "t00352", "t07275", "t12318", "t04931",
|
||||
"t13683", "t13686", "t13687", "t13688", "t13689", "t13690", "t13691", "t13769", "t13773",
|
||||
"t07151", "t13684", "t07523", "t12369", "t12567", "ar00627", "ar00052", "t00352", "t07275",
|
||||
"t12318", "t04931", "t13683", "t13686", "t13687", "t13688", "t13689", "t13690", "t13691",
|
||||
"t07766", "t07918", "t12993", "ar00044", "t13326", "t07614", "t12414",
|
||||
];
|
||||
|
||||
#[allow(clippy::mutex_atomic)]
|
||||
pub async fn test(test: &TestServer) {
|
||||
let store = test.server.search_store().clone();
|
||||
println!("Running Store query tests...");
|
||||
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(8)
|
||||
.build()
|
||||
.unwrap();
|
||||
let now = Instant::now();
|
||||
let documents = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut mask = RoaringBitmap::new();
|
||||
let mut fields = AHashMap::new();
|
||||
|
||||
// Global ids test
|
||||
println!("Running global id filtering tests...");
|
||||
test_global(store.clone()).await;
|
||||
|
||||
// Large document insert test
|
||||
println!("Running large document insert tests...");
|
||||
let mut large_text = String::with_capacity(20 * 1024 * 1024);
|
||||
while large_text.len() < 20 * 1024 * 1024 {
|
||||
let word = rand::rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(rand::rng().random_range(3..10))
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
large_text.push_str(&word);
|
||||
large_text.push(' ');
|
||||
}
|
||||
let mut document = IndexDocument::new(SearchIndex::Email)
|
||||
.with_account_id(1)
|
||||
.with_document_id(1);
|
||||
for field in [
|
||||
EmailSearchField::From,
|
||||
EmailSearchField::To,
|
||||
EmailSearchField::Cc,
|
||||
EmailSearchField::Bcc,
|
||||
EmailSearchField::Subject,
|
||||
] {
|
||||
document.index_text(field, &large_text[..10 * 1024], Language::English);
|
||||
}
|
||||
for field in [EmailSearchField::Body, EmailSearchField::Attachment] {
|
||||
document.index_text(field, &large_text, Language::English);
|
||||
}
|
||||
for field in [
|
||||
EmailSearchField::ReceivedAt,
|
||||
EmailSearchField::SentAt,
|
||||
EmailSearchField::Size,
|
||||
] {
|
||||
document.index_unsigned(field, rand::rng().random_range(100u64..1_000_000u64));
|
||||
}
|
||||
store.index(vec![document]).await.unwrap();
|
||||
// Refresh
|
||||
if let SearchStore::ElasticSearch(store) = &store {
|
||||
store.refresh_index(SearchIndex::Email).await.unwrap();
|
||||
}
|
||||
|
||||
println!("Running account filtering tests...");
|
||||
let filter_ids = std::env::var("QUICK_TEST").is_ok().then(|| {
|
||||
let mut ids = AHashSet::new();
|
||||
for &id in ALL_IDS {
|
||||
ids.insert(id.to_string());
|
||||
let id = id.as_bytes();
|
||||
if id.last().unwrap() > &b'0' {
|
||||
let mut alt_id = id.to_vec();
|
||||
*alt_id.last_mut().unwrap() -= 1;
|
||||
ids.insert(String::from_utf8(alt_id).unwrap());
|
||||
}
|
||||
if id.last().unwrap() < &b'9' {
|
||||
let mut alt_id = id.to_vec();
|
||||
*alt_id.last_mut().unwrap() += 1;
|
||||
ids.insert(String::from_utf8(alt_id).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
ids
|
||||
});
|
||||
|
||||
pool.scope_fifo(|s| {
|
||||
for (document_id, record) in csv::ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.from_reader(&deflate_test_resource("artwork_data.csv.gz")[..])
|
||||
.records()
|
||||
.enumerate()
|
||||
{
|
||||
let record = record.unwrap();
|
||||
let documents = documents.clone();
|
||||
|
||||
if let Some(filter_ids) = &filter_ids {
|
||||
let id = record.get(1).unwrap().to_lowercase();
|
||||
if !filter_ids.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
s.spawn_fifo(move |_| {
|
||||
let mut document = IndexDocument::new(SearchIndex::Email)
|
||||
.with_account_id(0)
|
||||
.with_document_id(document_id as u32);
|
||||
for (pos, field) in record.iter().enumerate() {
|
||||
match FIELD_MAPPINGS[pos] {
|
||||
EmailSearchField::From
|
||||
| EmailSearchField::To
|
||||
| EmailSearchField::Cc
|
||||
| EmailSearchField::Bcc => {
|
||||
document.index_text(
|
||||
FIELD_MAPPINGS[pos].clone(),
|
||||
&field.to_lowercase(),
|
||||
Language::None,
|
||||
);
|
||||
}
|
||||
EmailSearchField::Subject
|
||||
| EmailSearchField::Body
|
||||
| EmailSearchField::Attachment => {
|
||||
document.index_text(
|
||||
FIELD_MAPPINGS[pos].clone(),
|
||||
&field
|
||||
.replace(|ch: char| !ch.is_alphanumeric(), " ")
|
||||
.to_lowercase(),
|
||||
Language::English,
|
||||
);
|
||||
}
|
||||
EmailSearchField::Headers => {
|
||||
document.insert_key_value(
|
||||
EmailSearchField::Headers,
|
||||
"artist",
|
||||
field.to_lowercase(),
|
||||
);
|
||||
}
|
||||
EmailSearchField::ReceivedAt
|
||||
| EmailSearchField::SentAt
|
||||
| EmailSearchField::Size => {
|
||||
document.index_unsigned(
|
||||
FIELD_MAPPINGS[pos].clone(),
|
||||
field.parse::<u64>().unwrap_or(0),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
documents.lock().unwrap().push(document);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
println!(
|
||||
"Parsed {} entries in {} ms.",
|
||||
documents.lock().unwrap().len(),
|
||||
now.elapsed().as_millis()
|
||||
);
|
||||
|
||||
let now = Instant::now();
|
||||
let batches = documents.lock().unwrap().drain(..).collect::<Vec<_>>();
|
||||
|
||||
print!("Inserting... ",);
|
||||
let mut chunks = Vec::new();
|
||||
let mut chunk = Vec::new();
|
||||
for document in batches {
|
||||
let mut document_id = None;
|
||||
let mut to_field = None;
|
||||
|
||||
for (key, value) in document.fields() {
|
||||
if key == &SearchField::DocumentId {
|
||||
if let SearchValue::Uint(id) = value {
|
||||
document_id = Some(*id as u32);
|
||||
}
|
||||
} else if key == &SearchField::Email(EmailSearchField::To)
|
||||
&& let SearchValue::Text { value, .. } = value
|
||||
{
|
||||
to_field = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
let document_id = document_id.unwrap();
|
||||
let to_field = to_field.unwrap();
|
||||
mask.insert(document_id);
|
||||
fields.insert(document_id, to_field);
|
||||
|
||||
chunk.push(document);
|
||||
if chunk.len() == 10 {
|
||||
chunks.push(chunk);
|
||||
chunk = Vec::new();
|
||||
}
|
||||
}
|
||||
if !chunk.is_empty() {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
if test.is_reset() {
|
||||
let mut tasks = Vec::new();
|
||||
for chunk in chunks {
|
||||
let chunk_instance = Instant::now();
|
||||
tasks.push({
|
||||
let db = store.clone();
|
||||
tokio::spawn(async move { db.index(chunk).await })
|
||||
});
|
||||
|
||||
if tasks.len() == 100 {
|
||||
for handle in tasks {
|
||||
handle.await.unwrap().unwrap();
|
||||
}
|
||||
print!(" [{} ms]", chunk_instance.elapsed().as_millis());
|
||||
std::io::stdout().flush().unwrap();
|
||||
tasks = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
if !tasks.is_empty() {
|
||||
for handle in tasks {
|
||||
handle.await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh
|
||||
if let SearchStore::ElasticSearch(store) = &store {
|
||||
store.refresh_index(SearchIndex::Email).await.unwrap();
|
||||
}
|
||||
|
||||
println!("\nInsert took {} ms.", now.elapsed().as_millis());
|
||||
}
|
||||
|
||||
if store.internal_fts().is_none() {
|
||||
let ids = store
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Email)
|
||||
.with_filters(vec![SearchFilter::eq(SearchField::AccountId, 0u32)])
|
||||
.with_comparator(SearchComparator::ascending(EmailSearchField::ReceivedAt))
|
||||
.with_mask(mask.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<RoaringBitmap>();
|
||||
assert_eq!(ids, mask);
|
||||
let ids = store
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Email)
|
||||
.with_filters(vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::ge(SearchField::DocumentId, 0u32),
|
||||
])
|
||||
.with_mask(mask.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<RoaringBitmap>();
|
||||
assert_eq!(ids, mask);
|
||||
}
|
||||
|
||||
println!("Running account filter tests...");
|
||||
let now = Instant::now();
|
||||
test_filter(store.clone(), &fields, &mask).await;
|
||||
println!("Filtering took {} ms.", now.elapsed().as_millis());
|
||||
|
||||
println!("Running account sort tests...");
|
||||
let now = Instant::now();
|
||||
test_sort(store.clone(), &fields, &mask).await;
|
||||
println!("Sorting took {} ms.", now.elapsed().as_millis());
|
||||
|
||||
println!("Running unindex tests...");
|
||||
let now = Instant::now();
|
||||
test_unindex(store.clone(), &fields).await;
|
||||
println!("Unindexing took {} ms.", now.elapsed().as_millis());
|
||||
}
|
||||
|
||||
async fn test_filter(store: SearchStore, fields: &AHashMap<u32, String>, mask: &RoaringBitmap) {
|
||||
let can_stem = !store.is_mysql();
|
||||
let can_negate_text = !store.is_meilisearch();
|
||||
|
||||
let tests = [
|
||||
(
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "water"),
|
||||
SearchFilter::eq(EmailSearchField::ReceivedAt, 1979u32),
|
||||
],
|
||||
vec!["p11293"],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::has_keyword(EmailSearchField::From, "gelatin"),
|
||||
SearchFilter::gt(EmailSearchField::ReceivedAt, 2000u32),
|
||||
SearchFilter::lt(EmailSearchField::Size, 180u32),
|
||||
SearchFilter::gt(EmailSearchField::Size, 0u32),
|
||||
],
|
||||
vec!["p79426", "p79427", "p79428", "p79429", "p79430"],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'rustic bridge'"),
|
||||
],
|
||||
vec!["d05503"],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'rustic'"),
|
||||
SearchFilter::has_english_text(
|
||||
EmailSearchField::Subject,
|
||||
if can_stem { "study" } else { "studies" },
|
||||
),
|
||||
],
|
||||
vec!["d00399", "d05352"],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::cond(
|
||||
EmailSearchField::Headers,
|
||||
SearchOperator::Contains,
|
||||
SearchValue::KeyValues(VecMap::from_iter([(
|
||||
"artist".to_string(),
|
||||
"kunst, mauro".to_string(),
|
||||
)])),
|
||||
),
|
||||
SearchFilter::has_keyword(EmailSearchField::Cc, "artist"),
|
||||
SearchFilter::Or,
|
||||
SearchFilter::eq(EmailSearchField::ReceivedAt, 1969u32),
|
||||
SearchFilter::eq(EmailSearchField::ReceivedAt, 1971u32),
|
||||
SearchFilter::End,
|
||||
],
|
||||
vec!["p01764", "t05843"],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::Not,
|
||||
SearchFilter::has_keyword(EmailSearchField::From, "oil"),
|
||||
SearchFilter::End,
|
||||
SearchFilter::has_english_text(
|
||||
EmailSearchField::Body,
|
||||
if can_stem { "bequeath" } else { "bequeathed" },
|
||||
),
|
||||
SearchFilter::Or,
|
||||
SearchFilter::And,
|
||||
SearchFilter::ge(EmailSearchField::ReceivedAt, 1900u32),
|
||||
SearchFilter::lt(EmailSearchField::ReceivedAt, 1910u32),
|
||||
SearchFilter::End,
|
||||
SearchFilter::And,
|
||||
SearchFilter::ge(EmailSearchField::ReceivedAt, 2000u32),
|
||||
SearchFilter::lt(EmailSearchField::ReceivedAt, 2010u32),
|
||||
SearchFilter::End,
|
||||
SearchFilter::End,
|
||||
],
|
||||
vec![
|
||||
"n02478", "n02479", "n03568", "n03658", "n04327", "n04328", "n04721", "n04739",
|
||||
"n05095", "n05096", "n05145", "n05157", "n05158", "n05159", "n05298", "n05303",
|
||||
"n06070", "t01181", "t03571", "t05805", "t05806", "t12147", "t12154", "t12155",
|
||||
],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
SearchFilter::And,
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::cond(
|
||||
EmailSearchField::Headers,
|
||||
SearchOperator::Contains,
|
||||
SearchValue::KeyValues(VecMap::from_iter([(
|
||||
"artist".to_string(),
|
||||
"warhol".to_string(),
|
||||
)])),
|
||||
),
|
||||
SearchFilter::Not,
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'campbell'"),
|
||||
SearchFilter::End,
|
||||
SearchFilter::Not,
|
||||
SearchFilter::Or,
|
||||
SearchFilter::gt(EmailSearchField::ReceivedAt, 1980u32),
|
||||
SearchFilter::And,
|
||||
SearchFilter::gt(EmailSearchField::Size, 500u32),
|
||||
SearchFilter::gt(EmailSearchField::SentAt, 500u32),
|
||||
SearchFilter::End,
|
||||
SearchFilter::End,
|
||||
SearchFilter::End,
|
||||
SearchFilter::eq(EmailSearchField::Bcc, "2008".to_string()),
|
||||
SearchFilter::End,
|
||||
],
|
||||
vec!["ar00039", "t12600"],
|
||||
),
|
||||
(
|
||||
if can_stem {
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "study"),
|
||||
SearchFilter::has_keyword(EmailSearchField::From, "paper"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Body, "'purchased'"),
|
||||
SearchFilter::Not,
|
||||
SearchFilter::Or,
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'anatomical'"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'discarded'"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'untitled'"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'girl'"),
|
||||
SearchFilter::End,
|
||||
SearchFilter::End,
|
||||
SearchFilter::gt(EmailSearchField::ReceivedAt, 1900u32),
|
||||
SearchFilter::gt(EmailSearchField::Bcc, "2008".to_string()),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::Or,
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "study"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "studies"),
|
||||
SearchFilter::End,
|
||||
SearchFilter::has_keyword(EmailSearchField::From, "paper"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Body, "'purchased'"),
|
||||
SearchFilter::Not,
|
||||
SearchFilter::Or,
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'anatomical'"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'discarded'"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'untitled'"),
|
||||
SearchFilter::has_english_text(EmailSearchField::Subject, "'girl'"),
|
||||
SearchFilter::End,
|
||||
SearchFilter::End,
|
||||
SearchFilter::gt(EmailSearchField::ReceivedAt, 1900u32),
|
||||
SearchFilter::gt(EmailSearchField::Bcc, "2008".to_string()),
|
||||
]
|
||||
},
|
||||
vec!["p80203", "t13209", "t13560", "t13561"],
|
||||
),
|
||||
];
|
||||
|
||||
for (filters, expected_results) in tests {
|
||||
if !can_negate_text && has_negated_text(&filters) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//println!("Running test: {:?}", filter);
|
||||
let ids = store
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Email)
|
||||
.with_filters(filters)
|
||||
.with_comparator(SearchComparator::ascending(EmailSearchField::To))
|
||||
.with_mask(mask.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut results = Vec::new();
|
||||
for document_id in ids {
|
||||
results.push(fields.get(&document_id).unwrap());
|
||||
}
|
||||
assert_eq!(results, expected_results);
|
||||
}
|
||||
}
|
||||
|
||||
fn has_negated_text(filters: &[SearchFilter]) -> bool {
|
||||
let mut stack = Vec::new();
|
||||
let mut negated = 0;
|
||||
|
||||
for filter in filters {
|
||||
match filter {
|
||||
SearchFilter::Not => {
|
||||
stack.push(true);
|
||||
negated += 1;
|
||||
}
|
||||
SearchFilter::And | SearchFilter::Or => {
|
||||
stack.push(false);
|
||||
}
|
||||
SearchFilter::End => {
|
||||
if stack.pop().unwrap_or(false) {
|
||||
negated -= 1;
|
||||
}
|
||||
}
|
||||
SearchFilter::Operator {
|
||||
op: SearchOperator::Equal | SearchOperator::Contains,
|
||||
value: SearchValue::Text { .. },
|
||||
..
|
||||
} if negated > 0 => {
|
||||
return true;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
async fn test_sort(store: SearchStore, fields: &AHashMap<u32, String>, mask: &RoaringBitmap) {
|
||||
let is_reversed = store.is_postgres();
|
||||
|
||||
let tests = [
|
||||
(
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::gt(EmailSearchField::ReceivedAt, 0u32),
|
||||
SearchFilter::gt(EmailSearchField::Bcc, "0000".to_string()),
|
||||
SearchFilter::gt(EmailSearchField::Size, 0u32),
|
||||
],
|
||||
vec![
|
||||
SearchComparator::descending(EmailSearchField::ReceivedAt),
|
||||
SearchComparator::ascending(EmailSearchField::Bcc),
|
||||
SearchComparator::ascending(EmailSearchField::Size),
|
||||
SearchComparator::descending(EmailSearchField::To),
|
||||
],
|
||||
vec![
|
||||
"t13655", "t13811", "p13352", "p13351", "p13350", "p13349", "p13348", "p13347",
|
||||
"p13346", "p13345", "p13344", "p13342", "p13341", "p13340", "p13339", "p13338",
|
||||
"p13337", "p13336", "p13335", "p13334", "p13333", "p13332", "p13331", "p13330",
|
||||
"p13329", "p13328", "p13327", "p13326", "p13325", "p13324", "p13323", "t13786",
|
||||
"p13322", "p13321", "p13320", "p13319", "p13318", "p13317", "p13316", "p13315",
|
||||
"p13314", "t13588", "t13587", "t13586", "t13585", "t13584", "t13540", "t13444",
|
||||
"ar01154", "ar01153",
|
||||
],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
SearchFilter::eq(SearchField::AccountId, 0u32),
|
||||
SearchFilter::gt(EmailSearchField::Size, 0u32),
|
||||
SearchFilter::gt(EmailSearchField::SentAt, 0u32),
|
||||
],
|
||||
vec![
|
||||
SearchComparator::descending(EmailSearchField::Size),
|
||||
SearchComparator::ascending(EmailSearchField::SentAt),
|
||||
],
|
||||
vec![
|
||||
"t03681", "t12601", "ar00166", "t12625", "t12915", "p04182", "t06483", "ar00703",
|
||||
"t07671", "ar00021", "t05557", "t07918", "p06298", "p05465", "p06640", "t12855",
|
||||
"t01355", "t12800", "t12557", "t02078",
|
||||
],
|
||||
),
|
||||
(
|
||||
vec![SearchFilter::eq(SearchField::AccountId, 0u32)],
|
||||
vec![
|
||||
SearchComparator::descending(EmailSearchField::From),
|
||||
SearchComparator::descending(EmailSearchField::Cc),
|
||||
SearchComparator::ascending(EmailSearchField::To),
|
||||
],
|
||||
if is_reversed {
|
||||
vec![
|
||||
"ar00052", "ar00627", "t00352", "t07275", "t12318", "t04931", "t13683",
|
||||
"t13686", "t13687", "t13688", "t13689", "t13690", "t13691", "t13769", "t13773",
|
||||
"t07151", "t13684", "t07523", "t12369", "t12567",
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"ar00627", "ar00052", "t00352", "t07275", "t12318", "t04931", "t13683",
|
||||
"t13686", "t13687", "t13688", "t13689", "t13690", "t13691", "t07766", "t07918",
|
||||
"t12993", "ar00044", "t13326", "t07614", "t12414",
|
||||
]
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
for (filters, comparators, expected_results) in tests {
|
||||
//println!("Running test: {:?}", sort);
|
||||
let ids = store
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Email)
|
||||
.with_filters(filters)
|
||||
.with_comparators(comparators)
|
||||
.with_mask(mask.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut results = Vec::new();
|
||||
for document_id in ids.into_iter().take(expected_results.len()) {
|
||||
results.push(fields.get(&document_id).unwrap());
|
||||
}
|
||||
assert_eq!(results, expected_results);
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_unindex(store: SearchStore, fields: &AHashMap<u32, String>) {
|
||||
let ids = store
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Email)
|
||||
.with_mask(RoaringBitmap::from_iter(fields.keys().copied()))
|
||||
.with_filters(vec![
|
||||
SearchFilter::has_keyword(EmailSearchField::From, "gelatin"),
|
||||
SearchFilter::gt(EmailSearchField::ReceivedAt, 2000u32),
|
||||
SearchFilter::lt(EmailSearchField::Size, 180u32),
|
||||
SearchFilter::gt(EmailSearchField::Size, 0u32),
|
||||
])
|
||||
.with_account_id(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!ids.is_empty());
|
||||
let expected_count = ids.len().saturating_sub(10);
|
||||
|
||||
let mut query = SearchQuery::new(SearchIndex::Email)
|
||||
.with_account_id(0)
|
||||
.with_filter(SearchFilter::Or);
|
||||
for id in ids.into_iter().take(10) {
|
||||
query = query.with_filter(SearchFilter::eq(SearchField::DocumentId, id));
|
||||
}
|
||||
query = query.with_filter(SearchFilter::End);
|
||||
|
||||
store.unindex(query).await.unwrap();
|
||||
|
||||
// Refresh
|
||||
if let SearchStore::ElasticSearch(store) = &store {
|
||||
store.refresh_index(SearchIndex::Email).await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Email)
|
||||
.with_filters(vec![
|
||||
SearchFilter::has_keyword(EmailSearchField::From, "gelatin"),
|
||||
SearchFilter::gt(EmailSearchField::ReceivedAt, 2000u32),
|
||||
SearchFilter::lt(EmailSearchField::Size, 180u32),
|
||||
SearchFilter::gt(EmailSearchField::Size, 0u32),
|
||||
])
|
||||
.with_account_id(0)
|
||||
.with_mask(RoaringBitmap::from_iter(fields.keys().copied())),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
expected_count
|
||||
);
|
||||
}
|
||||
|
||||
async fn test_global(store: SearchStore) {
|
||||
// Insert global ids
|
||||
for (id, queue_id, etyp, keywords) in [
|
||||
(0, 1000u64, 1u64, "init start"),
|
||||
(1, 1000u64, 2u64, "init complete"),
|
||||
(2, 1001u64, 1u64, "process start"),
|
||||
(3, 1001u64, 2u64, "process complete"),
|
||||
(4, 1002u64, 1u64, "cleanup start"),
|
||||
(5, 1002u64, 2u64, "cleanup complete"),
|
||||
] {
|
||||
let mut document = IndexDocument::new(SearchIndex::Tracing).with_id(id);
|
||||
document.index_unsigned(TracingSearchField::QueueId, queue_id);
|
||||
document.index_unsigned(TracingSearchField::EventType, etyp);
|
||||
document.index_text(TracingSearchField::Keywords, keywords, Language::None);
|
||||
store.index(vec![document]).await.unwrap();
|
||||
}
|
||||
|
||||
// Refresh
|
||||
if let SearchStore::ElasticSearch(store) = &store {
|
||||
store.refresh_index(SearchIndex::Tracing).await.unwrap();
|
||||
}
|
||||
|
||||
// Query all
|
||||
assert_eq!(
|
||||
store
|
||||
.query_global(
|
||||
SearchQuery::new(SearchIndex::Tracing)
|
||||
.with_filter(SearchFilter::ge(SearchField::Id, 0u64))
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<AHashSet<_>>(),
|
||||
AHashSet::from_iter([0, 1, 2, 3, 4, 5])
|
||||
);
|
||||
|
||||
// Query with filter
|
||||
assert_eq!(
|
||||
store
|
||||
.query_global(
|
||||
SearchQuery::new(SearchIndex::Tracing)
|
||||
.with_filter(SearchFilter::gt(SearchField::Id, 1u64))
|
||||
.with_filter(SearchFilter::lt(SearchField::Id, 5u64))
|
||||
.with_filter(SearchFilter::has_keyword(
|
||||
TracingSearchField::Keywords,
|
||||
"start",
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<AHashSet<_>>(),
|
||||
AHashSet::from_iter([2, 4])
|
||||
);
|
||||
|
||||
// Delete by filter
|
||||
store
|
||||
.unindex(
|
||||
SearchQuery::new(SearchIndex::Tracing)
|
||||
.with_filter(SearchFilter::lt(SearchField::Id, 3u64)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Refresh
|
||||
if let SearchStore::ElasticSearch(store) = &store {
|
||||
store.refresh_index(SearchIndex::Tracing).await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.query_global(
|
||||
SearchQuery::new(SearchIndex::Tracing)
|
||||
.with_filter(SearchFilter::ge(SearchField::Id, 0u64))
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<AHashSet<_>>(),
|
||||
AHashSet::from_iter([3, 4, 5])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{registry::UnwrapRegistryId, server::TestServer};
|
||||
use jmap_tools::JsonPointer;
|
||||
use registry::{
|
||||
jmap::{IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, RegistryJsonPatch},
|
||||
pickle::{Pickle, PickledStream},
|
||||
schema::{
|
||||
enums::{AccountType, Locale, Permission, StorageQuota},
|
||||
prelude::{Object, ObjectType, Property},
|
||||
structs::{
|
||||
Account, CertificateManagement, Credential, CredentialPermissions,
|
||||
CredentialPermissionsList, CustomRoles, DkimManagement, DnsManagement, Domain,
|
||||
EmailAlias, EncryptionAtRest, EncryptionSettings, GroupAccount, MailingList,
|
||||
PasswordCredential, Permissions, PermissionsList, PublicKey, SecondaryCredential,
|
||||
SieveUserScript, UserAccount, UserRoles,
|
||||
},
|
||||
},
|
||||
types::{
|
||||
EnumImpl, ObjectImpl, datetime::UTCDateTime, id::ObjectId, ipmask::IpAddrOrMask,
|
||||
list::List, map::Map,
|
||||
},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use store::{
|
||||
registry::{
|
||||
RegistryQuery,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
write::now,
|
||||
};
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let r = test.server.registry();
|
||||
|
||||
println!("Registry tests...");
|
||||
|
||||
test_patch_regressions();
|
||||
|
||||
// Pickle-unpickle test
|
||||
let mut account = Account::User(UserAccount {
|
||||
aliases: List::from_iter([
|
||||
EmailAlias {
|
||||
description: "Test Alias 1".to_string().into(),
|
||||
domain_id: 1000u64.into(),
|
||||
enabled: true,
|
||||
name: "alias1".into(),
|
||||
},
|
||||
EmailAlias {
|
||||
description: "Test Alias 2".to_string().into(),
|
||||
domain_id: 1001u64.into(),
|
||||
enabled: true,
|
||||
name: "alias2".into(),
|
||||
},
|
||||
]),
|
||||
created_at: UTCDateTime::now(),
|
||||
credentials: List::from_iter([
|
||||
Credential::Password(PasswordCredential {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("192.168.1.1").unwrap()]),
|
||||
credential_id: 3u64.into(),
|
||||
expires_at: None,
|
||||
otp_auth: "otpauth://totp/test?secret=SECRET".to_string().into(),
|
||||
secret: "secret".into(),
|
||||
}),
|
||||
Credential::AppPassword(SecondaryCredential {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("192.168.1.0/24").unwrap()]),
|
||||
created_at: UTCDateTime::now(),
|
||||
credential_id: 4u64.into(),
|
||||
description: "App Password".into(),
|
||||
expires_at: Some(UTCDateTime::from_timestamp((now() + 1000) as i64)),
|
||||
permissions: CredentialPermissions::Disable(CredentialPermissionsList {
|
||||
permissions: Map::new(vec![
|
||||
Permission::Authenticate,
|
||||
Permission::ActionClassifySpam,
|
||||
]),
|
||||
}),
|
||||
secret: "app_password_secret".into(),
|
||||
}),
|
||||
]),
|
||||
description: "This is a test Account".to_string().into(),
|
||||
domain_id: 1004u64.into(),
|
||||
encryption_at_rest: EncryptionAtRest::Aes128(EncryptionSettings {
|
||||
allow_spam_training: true,
|
||||
encrypt_on_append: false,
|
||||
public_key: 0u64.into(),
|
||||
}),
|
||||
external_id: "8f7c1e2a-4b3d-4f1a-9c2e-7d5b6a8f0e11".to_string().into(),
|
||||
locale: Locale::EnUS,
|
||||
member_group_ids: Map::new(vec![2000u64.into(), 2001u64.into()]),
|
||||
member_tenant_id: None,
|
||||
name: "user".into(),
|
||||
permissions: Permissions::Merge(PermissionsList {
|
||||
disabled_permissions: Map::new(vec![Permission::Impersonate]),
|
||||
enabled_permissions: Map::new(vec![Permission::JmapBlobGet]),
|
||||
}),
|
||||
quotas: VecMap::from_iter([
|
||||
(StorageQuota::MaxDiskQuota, 1024u64),
|
||||
(StorageQuota::MaxApiKeys, 3u64),
|
||||
]),
|
||||
roles: UserRoles::Custom(CustomRoles {
|
||||
role_ids: Map::new(vec![5000u64.into()]),
|
||||
}),
|
||||
time_zone: None,
|
||||
});
|
||||
let account_pickle = account.to_pickled_vec();
|
||||
assert_eq!(
|
||||
account,
|
||||
Account::unpickle(&mut PickledStream::new(&account_pickle).unwrap()).unwrap()
|
||||
);
|
||||
|
||||
// Pickle compression test
|
||||
let script = SieveUserScript {
|
||||
contents: "A".repeat(100_000),
|
||||
description: "B".repeat(100_000).into(),
|
||||
is_active: true,
|
||||
name: "C".repeat(100_000),
|
||||
};
|
||||
let script_pickle = script.to_pickled_vec();
|
||||
assert!(
|
||||
script_pickle.len() < 8_192,
|
||||
"Pickle was not compressed: {} bytes",
|
||||
script_pickle.len()
|
||||
);
|
||||
assert_eq!(
|
||||
script,
|
||||
SieveUserScript::unpickle(&mut PickledStream::new(&script_pickle).unwrap()).unwrap()
|
||||
);
|
||||
|
||||
// Create a domain and a group
|
||||
let domain_id = r
|
||||
.write(RegistryWrite::insert(
|
||||
&Domain {
|
||||
name: "test.org".into(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
is_enabled: true,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
let domain_id_2 = r
|
||||
.write(RegistryWrite::insert(
|
||||
&Domain {
|
||||
name: "test.net".into(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
is_enabled: true,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
let group_id = r
|
||||
.write(RegistryWrite::insert(
|
||||
&Account::Group(GroupAccount {
|
||||
name: "group".into(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
// Inserting an account linking non-existing ids should fail
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::Account, Id::new(2000)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", Id::new(2000)),
|
||||
false,
|
||||
trc::location!(),
|
||||
);
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", Id::new(2001)),
|
||||
false,
|
||||
trc::location!(),
|
||||
);
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", group_id),
|
||||
true,
|
||||
trc::location!(),
|
||||
);
|
||||
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::Domain, Id::new(1000)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch("aliases/0/domainId", domain_id, trc::location!());
|
||||
account.assert_patch("aliases/1/domainId", domain_id, trc::location!());
|
||||
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::Domain, Id::new(1004)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch("domainId", domain_id, trc::location!());
|
||||
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::PublicKey, Id::new(0)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch(
|
||||
"encryptionAtRest",
|
||||
EncryptionAtRest::Disabled.into_value(),
|
||||
trc::location!(),
|
||||
);
|
||||
|
||||
test.assert_registry_insert_error(
|
||||
account.clone(),
|
||||
RegistryWriteResult::InvalidForeignKey {
|
||||
object_id: ObjectId::new(ObjectType::Role, Id::new(5000)),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
account.assert_patch("roles", UserRoles::User.into_value(), trc::location!());
|
||||
|
||||
let account_id = r
|
||||
.write(RegistryWrite::insert(&account.into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
// Deleting linked objects should fail
|
||||
test.assert_registry_delete_error(
|
||||
ObjectType::Domain,
|
||||
domain_id,
|
||||
RegistryWriteResult::CannotDeleteLinked {
|
||||
object_id: ObjectId::new(ObjectType::Domain, domain_id),
|
||||
linked_objects: vec![
|
||||
ObjectId::new(ObjectType::Account, group_id),
|
||||
ObjectId::new(ObjectType::Account, account_id),
|
||||
],
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Primary key violations should not be allowed
|
||||
test.assert_registry_insert_error(
|
||||
Domain {
|
||||
name: "test.org".into(),
|
||||
is_enabled: true,
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
..Default::default()
|
||||
},
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property: Property::Name,
|
||||
existing_id: ObjectId::new(ObjectType::Domain, domain_id),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
test.assert_registry_insert_error(
|
||||
Account::Group(GroupAccount {
|
||||
name: "group".into(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
}),
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property: Property::Email,
|
||||
existing_id: ObjectId::new(ObjectType::Account, group_id),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
test.assert_registry_insert_error(
|
||||
MailingList {
|
||||
name: "user".into(),
|
||||
domain_id,
|
||||
recipients: Map::new(vec!["[email protected]".into()]),
|
||||
..Default::default()
|
||||
},
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property: Property::Email,
|
||||
existing_id: ObjectId::new(ObjectType::Account, account_id),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
test.assert_registry_insert_error(
|
||||
MailingList {
|
||||
name: "mailing-list".into(),
|
||||
domain_id,
|
||||
aliases: List::from_iter([EmailAlias {
|
||||
description: "Test Alias 1".to_string().into(),
|
||||
domain_id,
|
||||
enabled: true,
|
||||
name: "alias1".into(),
|
||||
}]),
|
||||
recipients: Map::new(vec!["[email protected]".into()]),
|
||||
..Default::default()
|
||||
},
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property: Property::Email,
|
||||
existing_id: ObjectId::new(ObjectType::Account, account_id),
|
||||
},
|
||||
trc::location!(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create a public key and link it to the account
|
||||
let pk_id = r
|
||||
.write(RegistryWrite::insert(
|
||||
&PublicKey {
|
||||
account_id,
|
||||
key: "secret".into(),
|
||||
description: "Test Key".into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
let old_account = r
|
||||
.get(ObjectId::new(ObjectType::Account, account_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut account = old_account.clone();
|
||||
assert_obj_patch(
|
||||
&mut account,
|
||||
"encryptionAtRest",
|
||||
EncryptionAtRest::Aes128(EncryptionSettings {
|
||||
allow_spam_training: true,
|
||||
encrypt_on_append: false,
|
||||
public_key: pk_id,
|
||||
})
|
||||
.into_value(),
|
||||
trc::location!(),
|
||||
);
|
||||
r.write(RegistryWrite::update(account_id, &account, &old_account))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
// Search tests
|
||||
assert_eq!(
|
||||
r.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain))
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![domain_id, domain_id_2]
|
||||
);
|
||||
assert_eq!(
|
||||
r.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain).equal_pk(
|
||||
Property::Name,
|
||||
"test.org".to_string(),
|
||||
true,
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![domain_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![group_id, account_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.query::<Vec<Id>>(
|
||||
RegistryQuery::new(ObjectType::Account)
|
||||
.equal(Property::Type, AccountType::User.to_id())
|
||||
.text(Property::Text, "this is a test")
|
||||
.equal(Property::Name, "user")
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![account_id]
|
||||
);
|
||||
|
||||
// Sort test
|
||||
assert_eq!(
|
||||
r.sort_by_index(ObjectType::Account, Property::Type, None, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![account_id, group_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sort_by_index(
|
||||
ObjectType::Account,
|
||||
Property::Type,
|
||||
Some(vec![group_id, account_id]),
|
||||
true
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![account_id, group_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sort_by_index(ObjectType::Account, Property::Name, None, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![group_id, account_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sort_by_pk(ObjectType::Domain, Property::Name, None, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![domain_id_2, domain_id]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sort_by_pk(
|
||||
ObjectType::Domain,
|
||||
Property::Name,
|
||||
Some(vec![domain_id, domain_id_2]),
|
||||
true
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![domain_id_2, domain_id]
|
||||
);
|
||||
|
||||
// Delete everything
|
||||
let old_account = r
|
||||
.get(ObjectId::new(ObjectType::Account, account_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut account = old_account.clone();
|
||||
assert_obj_patch(
|
||||
&mut account,
|
||||
"encryptionAtRest",
|
||||
EncryptionAtRest::Disabled.into_value(),
|
||||
trc::location!(),
|
||||
);
|
||||
r.write(RegistryWrite::update(account_id, &account, &old_account))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::PublicKey,
|
||||
pk_id,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::Account,
|
||||
account_id,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::Account,
|
||||
group_id,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::Domain,
|
||||
domain_id,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
r.write(RegistryWrite::delete(ObjectId::new(
|
||||
ObjectType::Domain,
|
||||
domain_id_2,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
pub async fn assert_registry_insert_error(
|
||||
&self,
|
||||
obj: impl Into<Object>,
|
||||
result: RegistryWriteResult,
|
||||
location: &str,
|
||||
) {
|
||||
let obj = obj.into();
|
||||
|
||||
assert_eq!(
|
||||
self.server
|
||||
.registry()
|
||||
.write(RegistryWrite::insert(&obj))
|
||||
.await
|
||||
.unwrap(),
|
||||
result,
|
||||
"{}",
|
||||
location
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn assert_registry_delete_error(
|
||||
&self,
|
||||
object_type: ObjectType,
|
||||
id: Id,
|
||||
result: RegistryWriteResult,
|
||||
location: &str,
|
||||
) {
|
||||
assert_eq!(
|
||||
self.server
|
||||
.registry()
|
||||
.write(RegistryWrite::delete(ObjectId::new(object_type, id)))
|
||||
.await
|
||||
.unwrap(),
|
||||
result,
|
||||
"{}",
|
||||
location
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_patch_regressions() {
|
||||
fn fresh_account() -> Account {
|
||||
Account::User(UserAccount {
|
||||
credentials: List::from_iter([
|
||||
Credential::Password(PasswordCredential {
|
||||
allowed_ips: Map::new(vec![
|
||||
IpAddrOrMask::from_str("192.168.1.1").unwrap(),
|
||||
IpAddrOrMask::from_str("192.168.1.2").unwrap(),
|
||||
]),
|
||||
credential_id: 3u64.into(),
|
||||
expires_at: None,
|
||||
otp_auth: None,
|
||||
secret: "secret".into(),
|
||||
}),
|
||||
Credential::Password(PasswordCredential {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.1").unwrap()]),
|
||||
credential_id: 4u64.into(),
|
||||
expires_at: None,
|
||||
otp_auth: None,
|
||||
secret: "another".into(),
|
||||
}),
|
||||
]),
|
||||
domain_id: 1u64.into(),
|
||||
name: "patch-target".into(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn user(account: &Account) -> &UserAccount {
|
||||
match account {
|
||||
Account::User(u) => u,
|
||||
_ => panic!("expected user account"),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_mut(account: &mut Account) -> &mut UserAccount {
|
||||
match account {
|
||||
Account::User(u) => u,
|
||||
_ => panic!("expected user account"),
|
||||
}
|
||||
}
|
||||
|
||||
fn password_at(account: &Account, idx: u32) -> &PasswordCredential {
|
||||
let cred = user(account)
|
||||
.credentials
|
||||
.0
|
||||
.get(&idx)
|
||||
.expect("credential at index");
|
||||
match cred {
|
||||
Credential::Password(p) => p,
|
||||
_ => panic!("expected password credential at idx {idx}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf-null patch into a List<T> entry removes only the leaf not the whole entry.
|
||||
let mut account = fresh_account();
|
||||
account.assert_patch(
|
||||
"credentials/0/allowedIps/192.168.1.1",
|
||||
JmapValue::Null,
|
||||
trc::location!(),
|
||||
);
|
||||
{
|
||||
let cred = password_at(&account, 0);
|
||||
assert_eq!(cred.allowed_ips.len(), 1, "one ip should remain");
|
||||
assert!(
|
||||
cred.allowed_ips
|
||||
.contains(&IpAddrOrMask::from_str("192.168.1.2").unwrap()),
|
||||
"remaining ip survived"
|
||||
);
|
||||
assert!(
|
||||
!cred
|
||||
.allowed_ips
|
||||
.contains(&IpAddrOrMask::from_str("192.168.1.1").unwrap()),
|
||||
"targeted ip removed"
|
||||
);
|
||||
}
|
||||
// The sibling credential is untouched.
|
||||
{
|
||||
let cred = password_at(&account, 1);
|
||||
assert_eq!(cred.allowed_ips.len(), 1);
|
||||
assert!(
|
||||
cred.allowed_ips
|
||||
.contains(&IpAddrOrMask::from_str("10.0.0.1").unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
// Removing every leaf still leaves the entry in place with an empty map.
|
||||
let mut account = fresh_account();
|
||||
account.assert_patch(
|
||||
"credentials/0/allowedIps/192.168.1.1",
|
||||
JmapValue::Null,
|
||||
trc::location!(),
|
||||
);
|
||||
account.assert_patch(
|
||||
"credentials/0/allowedIps/192.168.1.2",
|
||||
JmapValue::Null,
|
||||
trc::location!(),
|
||||
);
|
||||
{
|
||||
assert_eq!(
|
||||
user(&account).credentials.len(),
|
||||
2,
|
||||
"credential entry retained"
|
||||
);
|
||||
let cred = password_at(&account, 0);
|
||||
assert!(cred.allowed_ips.is_empty(), "leaf map drained");
|
||||
}
|
||||
|
||||
// Direct removal of a list entry with no remaining segments still works.
|
||||
let mut account = fresh_account();
|
||||
account.assert_patch("credentials/0", JmapValue::Null, trc::location!());
|
||||
{
|
||||
assert_eq!(user(&account).credentials.len(), 1, "credential 0 removed");
|
||||
let cred = password_at(&account, 1);
|
||||
assert_eq!(cred.allowed_ips.len(), 1);
|
||||
}
|
||||
|
||||
// Leaf-null patch into a scalar property of a list entry clears only that property.
|
||||
let mut account = fresh_account();
|
||||
{
|
||||
let cred = user_mut(&mut account)
|
||||
.credentials
|
||||
.inner_mut()
|
||||
.get_mut(&0)
|
||||
.expect("credential at 0");
|
||||
if let Credential::Password(p) = cred {
|
||||
p.expires_at = Some(UTCDateTime::from_timestamp(now() as i64));
|
||||
}
|
||||
}
|
||||
account.assert_patch("credentials/0/expiresAt", JmapValue::Null, trc::location!());
|
||||
{
|
||||
let cred = password_at(&account, 0);
|
||||
assert!(cred.expires_at.is_none(), "expiresAt cleared");
|
||||
assert_eq!(cred.allowed_ips.len(), 2, "siblings untouched");
|
||||
}
|
||||
|
||||
// Map<T> set-style patches
|
||||
fn account_with_groups() -> Account {
|
||||
let mut account = match fresh_account() {
|
||||
Account::User(u) => u,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
account.member_group_ids = Map::new(vec![Id::new(2000), Id::new(2001)]);
|
||||
Account::User(account)
|
||||
}
|
||||
|
||||
let mut account = account_with_groups();
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", Id::new(2000)),
|
||||
JmapValue::Null,
|
||||
trc::location!(),
|
||||
);
|
||||
assert_eq!(
|
||||
user(&account).member_group_ids.len(),
|
||||
1,
|
||||
"one member removed"
|
||||
);
|
||||
assert!(
|
||||
user(&account).member_group_ids.contains(&Id::new(2001)),
|
||||
"sibling preserved"
|
||||
);
|
||||
|
||||
let mut account = account_with_groups();
|
||||
let extra_path = format!("memberGroupIds/{}/extra", Id::new(2000));
|
||||
let ptr = JsonPointer::parse(&extra_path);
|
||||
let outcome = account.patch(JsonPointerPatch::new(&ptr), JmapValue::Null);
|
||||
assert!(outcome.is_err(), "extra segments must error on remove");
|
||||
assert_eq!(
|
||||
user(&account).member_group_ids.len(),
|
||||
2,
|
||||
"membership unchanged after rejected patch"
|
||||
);
|
||||
|
||||
let mut account = account_with_groups();
|
||||
let ptr = JsonPointer::parse(&format!("memberGroupIds/{}/extra", Id::new(2002)));
|
||||
let outcome = account.patch(JsonPointerPatch::new(&ptr), JmapValue::Bool(true));
|
||||
assert!(outcome.is_err(), "extra segments must error on add");
|
||||
assert_eq!(
|
||||
user(&account).member_group_ids.len(),
|
||||
2,
|
||||
"membership unchanged after rejected add"
|
||||
);
|
||||
|
||||
// Direct adds and removes still work.
|
||||
let mut account = account_with_groups();
|
||||
account.assert_patch(
|
||||
&format!("memberGroupIds/{}", Id::new(2002)),
|
||||
true,
|
||||
trc::location!(),
|
||||
);
|
||||
assert_eq!(user(&account).member_group_ids.len(), 3, "member added");
|
||||
assert!(user(&account).member_group_ids.contains(&Id::new(2002)));
|
||||
}
|
||||
|
||||
trait AssertPatch {
|
||||
fn assert_patch(&mut self, patch: &str, value: impl Into<JmapValue<'static>>, location: &str);
|
||||
}
|
||||
|
||||
impl<T: RegistryJsonPatch> AssertPatch for T {
|
||||
fn assert_patch(&mut self, patch: &str, value: impl Into<JmapValue<'static>>, location: &str) {
|
||||
let ptr = JsonPointer::parse(patch);
|
||||
let patch = JsonPointerPatch::new(&ptr);
|
||||
let value = value.into();
|
||||
match self.patch(patch, value) {
|
||||
Ok(maybe_unpatched) => {
|
||||
match maybe_unpatched {
|
||||
MaybeUnpatched::Patched => {
|
||||
// Patch succeeded
|
||||
}
|
||||
MaybeUnpatched::Unpatched { property, value } => {
|
||||
panic!(
|
||||
"Expected patch to succeed but it was unpatched at {}: property: {}, value: {:?}",
|
||||
location, property, value
|
||||
);
|
||||
}
|
||||
MaybeUnpatched::UnpatchedMany { properties } => {
|
||||
panic!(
|
||||
"Expected patch to succeed but it was unpatched at {}: properties: {:?}",
|
||||
location, properties
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => panic!("Patch failed at {}: {:?}", location, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_obj_patch(
|
||||
obj: &mut Object,
|
||||
patch: &str,
|
||||
value: impl Into<JmapValue<'static>>,
|
||||
location: &str,
|
||||
) {
|
||||
let ptr = JsonPointer::parse(patch);
|
||||
let patch = JsonPointerPatch::new(&ptr);
|
||||
let value = value.into();
|
||||
match obj.patch(patch, value) {
|
||||
Ok(maybe_unpatched) => {
|
||||
match maybe_unpatched {
|
||||
MaybeUnpatched::Patched => {
|
||||
// Patch succeeded
|
||||
}
|
||||
MaybeUnpatched::Unpatched { property, value } => {
|
||||
panic!(
|
||||
"Expected patch to succeed but it was unpatched at {}: property: {}, value: {:?}",
|
||||
location, property, value
|
||||
);
|
||||
}
|
||||
MaybeUnpatched::UnpatchedMany { properties } => {
|
||||
panic!(
|
||||
"Expected patch to succeed but it was unpatched at {}: properties: {:?}",
|
||||
location, properties
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => panic!("Patch failed at {}: {:?}", location, err),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{server::TestServer, storage::build_data_store};
|
||||
use futures::FutureExt;
|
||||
use registry::schema::structs::DataStore;
|
||||
use std::{panic::AssertUnwindSafe, time::Duration};
|
||||
use store::{
|
||||
IterateParams, Key, Rows, SUBSPACE_COUNTER, SUBSPACE_PROPERTY, Store, U32_LEN, Value, ValueKey,
|
||||
write::ValueClass,
|
||||
};
|
||||
use types::collection::Collection;
|
||||
|
||||
const ACCOUNT_ID: u32 = 90210;
|
||||
const NUM_KEYS: u32 = 300000;
|
||||
const VALUE_SIZE: usize = 64;
|
||||
const PROPERTY: u8 = 128;
|
||||
const STATEMENT_TIMEOUT: Duration = Duration::from_millis(10);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Backend {
|
||||
#[cfg(feature = "postgres")]
|
||||
Postgres,
|
||||
#[cfg(feature = "mysql")]
|
||||
MariaDb,
|
||||
}
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let Some(backend) = Backend::detect(test.server.store()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
println!("Running SQL statement timeout tests...");
|
||||
|
||||
let admin = Store::build(backend.data_store(false).await)
|
||||
.await
|
||||
.expect("Failed to connect to the statement timeout store");
|
||||
admin.create_tables().await.unwrap();
|
||||
admin.delete_range(key(0), key(u32::MAX)).await.unwrap();
|
||||
for query in [backend.populate_query(), backend.populate_counters_query()] {
|
||||
admin
|
||||
.sql_query::<usize>(&query, vec![])
|
||||
.await
|
||||
.expect("Failed to populate the statement timeout store");
|
||||
}
|
||||
backend.set_timeout(&admin, true).await;
|
||||
|
||||
let slow = Store::build(backend.data_store(true).await)
|
||||
.await
|
||||
.expect("Failed to open the statement timeout connection pool");
|
||||
let result = AssertUnwindSafe(scenarios(&slow, backend))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
backend.set_timeout(&admin, false).await;
|
||||
|
||||
let mut remaining = 0;
|
||||
let mut counters = String::new();
|
||||
if result.is_ok() {
|
||||
admin
|
||||
.iterate(
|
||||
IterateParams::new(key(0), key(u32::MAX)).no_values(),
|
||||
|_, _| {
|
||||
remaining += 1;
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to count remaining keys");
|
||||
counters = admin
|
||||
.sql_query::<Rows>(&backend.count_counters_query(), range_params())
|
||||
.await
|
||||
.expect("Failed to count remaining counters")
|
||||
.rows
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|row| row.values.into_iter().next())
|
||||
.map(|value| value.to_str().into_owned())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
admin.delete_range(key(0), key(u32::MAX)).await.unwrap();
|
||||
admin
|
||||
.sql_query::<usize>(&backend.purge_query(), vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let Err(err) = result {
|
||||
std::panic::resume_unwind(err);
|
||||
}
|
||||
|
||||
assert_eq!(remaining, 0, "Keys left behind by delete_range");
|
||||
assert_eq!(counters, "0", "Counters left behind by purge_store");
|
||||
}
|
||||
|
||||
async fn scenarios(db: &Store, backend: Backend) {
|
||||
match db
|
||||
.sql_query::<Rows>(&backend.scan_query(), range_params())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => panic!(
|
||||
"Unable to reproduce a statement timeout: scanning {} keys completed within a \
|
||||
{STATEMENT_TIMEOUT:?} statement timeout",
|
||||
rows.rows.len()
|
||||
),
|
||||
Err(err) => backend.assert_timeout(&err),
|
||||
}
|
||||
|
||||
let mut ids = Vec::with_capacity(NUM_KEYS as usize);
|
||||
db.iterate(IterateParams::new(key(0), key(u32::MAX)), |key, value| {
|
||||
let document_id = document_id(key);
|
||||
assert_eq!(value.len(), VALUE_SIZE, "document {document_id}");
|
||||
assert_eq!(
|
||||
value.get(..U32_LEN),
|
||||
Some(document_id.to_be_bytes().as_slice()),
|
||||
"document {document_id}"
|
||||
);
|
||||
ids.push(document_id);
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.expect("Failed to iterate in ascending order");
|
||||
assert_ids(&ids, 0..NUM_KEYS);
|
||||
|
||||
ids.clear();
|
||||
db.iterate(
|
||||
IterateParams::new(key(0), key(u32::MAX)).descending(),
|
||||
|key, _| {
|
||||
ids.push(document_id(key));
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to iterate in descending order");
|
||||
assert_ids(&ids, (0..NUM_KEYS).rev());
|
||||
|
||||
ids.clear();
|
||||
db.iterate(
|
||||
IterateParams::new(key(0), key(u32::MAX)).no_values(),
|
||||
|key, value| {
|
||||
assert!(value.is_empty());
|
||||
ids.push(document_id(key));
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to iterate over keys");
|
||||
assert_ids(&ids, 0..NUM_KEYS);
|
||||
|
||||
ids.clear();
|
||||
db.iterate(IterateParams::new(key(0), key(u32::MAX)), |key, _| {
|
||||
ids.push(document_id(key));
|
||||
Ok(ids.len() < 10)
|
||||
})
|
||||
.await
|
||||
.expect("Failed to stop iterating");
|
||||
assert_ids(&ids, 0..10);
|
||||
|
||||
match db
|
||||
.sql_query::<usize>(&backend.delete_query(), range_params())
|
||||
.await
|
||||
{
|
||||
Ok(deleted) => panic!(
|
||||
"Unable to reproduce a statement timeout: deleting {deleted} keys completed within \
|
||||
a {STATEMENT_TIMEOUT:?} statement timeout"
|
||||
),
|
||||
Err(err) => backend.assert_timeout(&err),
|
||||
}
|
||||
|
||||
db.delete_range(key(0), key(u32::MAX))
|
||||
.await
|
||||
.expect("Failed to delete range");
|
||||
|
||||
match db.sql_query::<usize>(&backend.purge_query(), vec![]).await {
|
||||
Ok(deleted) => panic!(
|
||||
"Unable to reproduce a statement timeout: purging {deleted} counters completed \
|
||||
within a {STATEMENT_TIMEOUT:?} statement timeout"
|
||||
),
|
||||
Err(err) => backend.assert_timeout(&err),
|
||||
}
|
||||
|
||||
db.purge_store().await.expect("Failed to purge store");
|
||||
}
|
||||
|
||||
fn assert_ids(ids: &[u32], expected: impl ExactSizeIterator<Item = u32>) {
|
||||
assert_eq!(ids.len(), expected.len(), "Unexpected number of keys");
|
||||
|
||||
for (position, (id, expected)) in ids.iter().zip(expected).enumerate() {
|
||||
assert_eq!(*id, expected, "Unexpected key at position {position}");
|
||||
}
|
||||
}
|
||||
|
||||
fn key(document_id: u32) -> ValueKey<ValueClass> {
|
||||
ValueKey {
|
||||
account_id: ACCOUNT_ID,
|
||||
collection: Collection::Email.into(),
|
||||
document_id,
|
||||
class: ValueClass::Property(PROPERTY),
|
||||
}
|
||||
}
|
||||
|
||||
fn document_id(key: &[u8]) -> u32 {
|
||||
u32::from_be_bytes(key[key.len() - U32_LEN..].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn key_prefix() -> String {
|
||||
let key = key(0).serialize(0);
|
||||
key[..key.len() - U32_LEN]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn range_params() -> Vec<Value<'static>> {
|
||||
vec![
|
||||
Value::Blob(key(0).serialize(0).into()),
|
||||
Value::Blob(key(u32::MAX).serialize(0).into()),
|
||||
]
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
fn detect(db: &Store) -> Option<Self> {
|
||||
match db {
|
||||
#[cfg(feature = "postgres")]
|
||||
Store::PostgreSQL(_) => Some(Backend::Postgres),
|
||||
#[cfg(feature = "mysql")]
|
||||
Store::MySQL(_) => Some(Backend::MariaDb),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn data_store(&self, slow: bool) -> DataStore {
|
||||
let mut config = build_data_store(
|
||||
match self {
|
||||
#[cfg(feature = "postgres")]
|
||||
Backend::Postgres => "PostgreSql",
|
||||
#[cfg(feature = "mysql")]
|
||||
Backend::MariaDb => "MariaDb",
|
||||
},
|
||||
"",
|
||||
)
|
||||
.await;
|
||||
|
||||
if slow {
|
||||
match &mut config {
|
||||
DataStore::PostgreSql(config) => {
|
||||
config.options = Some(format!(
|
||||
"-c statement_timeout={}ms",
|
||||
STATEMENT_TIMEOUT.as_millis()
|
||||
));
|
||||
}
|
||||
DataStore::MySql(_) => (),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
async fn set_timeout(&self, _admin: &Store, _enable: bool) {
|
||||
match self {
|
||||
#[cfg(feature = "postgres")]
|
||||
Backend::Postgres => (),
|
||||
#[cfg(feature = "mysql")]
|
||||
Backend::MariaDb => {
|
||||
let timeout = if _enable {
|
||||
STATEMENT_TIMEOUT.as_secs_f64()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
_admin
|
||||
.sql_query::<usize>(
|
||||
&format!("SET GLOBAL max_statement_time = {timeout}"),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_query(&self) -> String {
|
||||
let table = char::from(SUBSPACE_PROPERTY);
|
||||
let prefix = key_prefix();
|
||||
let padding = VALUE_SIZE - U32_LEN;
|
||||
let last = NUM_KEYS - 1;
|
||||
|
||||
match self {
|
||||
#[cfg(feature = "postgres")]
|
||||
Backend::Postgres => format!(
|
||||
"INSERT INTO {table} (k, v) SELECT \
|
||||
decode('{prefix}' || lpad(to_hex(id), 8, '0'), 'hex'), \
|
||||
decode(lpad(to_hex(id), 8, '0') || repeat('76', {padding}), 'hex') \
|
||||
FROM generate_series(0, {last}) id"
|
||||
),
|
||||
#[cfg(feature = "mysql")]
|
||||
Backend::MariaDb => format!(
|
||||
"INSERT INTO {table} (k, v) SELECT \
|
||||
UNHEX(CONCAT('{prefix}', LPAD(HEX(seq), 8, '0'))), \
|
||||
UNHEX(CONCAT(LPAD(HEX(seq), 8, '0'), REPEAT('76', {padding}))) \
|
||||
FROM seq_0_to_{last}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_counters_query(&self) -> String {
|
||||
let table = char::from(SUBSPACE_COUNTER);
|
||||
let prefix = key_prefix();
|
||||
let last = NUM_KEYS - 1;
|
||||
|
||||
match self {
|
||||
#[cfg(feature = "postgres")]
|
||||
Backend::Postgres => format!(
|
||||
"INSERT INTO {table} (k, v) SELECT \
|
||||
decode('{prefix}' || lpad(to_hex(id), 8, '0'), 'hex'), 0 \
|
||||
FROM generate_series(0, {last}) id"
|
||||
),
|
||||
#[cfg(feature = "mysql")]
|
||||
Backend::MariaDb => format!(
|
||||
"INSERT INTO {table} (k, v) SELECT \
|
||||
UNHEX(CONCAT('{prefix}', LPAD(HEX(seq), 8, '0'))), 0 FROM seq_0_to_{last}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn purge_query(&self) -> String {
|
||||
format!("DELETE FROM {} WHERE v = 0", char::from(SUBSPACE_COUNTER))
|
||||
}
|
||||
|
||||
fn count_counters_query(&self) -> String {
|
||||
let table = char::from(SUBSPACE_COUNTER);
|
||||
match self {
|
||||
#[cfg(feature = "postgres")]
|
||||
Backend::Postgres => format!("SELECT COUNT(*) FROM {table} WHERE k >= $1 AND k <= $2"),
|
||||
#[cfg(feature = "mysql")]
|
||||
Backend::MariaDb => format!("SELECT COUNT(*) FROM {table} WHERE k >= ? AND k <= ?"),
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_query(&self) -> String {
|
||||
let table = char::from(SUBSPACE_PROPERTY);
|
||||
match self {
|
||||
#[cfg(feature = "postgres")]
|
||||
Backend::Postgres => {
|
||||
format!("SELECT k, v FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC")
|
||||
}
|
||||
#[cfg(feature = "mysql")]
|
||||
Backend::MariaDb => {
|
||||
format!("SELECT k, v FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_query(&self) -> String {
|
||||
let table = char::from(SUBSPACE_PROPERTY);
|
||||
match self {
|
||||
#[cfg(feature = "postgres")]
|
||||
Backend::Postgres => format!("DELETE FROM {table} WHERE k >= $1 AND k <= $2"),
|
||||
#[cfg(feature = "mysql")]
|
||||
Backend::MariaDb => format!("DELETE FROM {table} WHERE k >= ? AND k <= ?"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_timeout(&self, err: &trc::Error) {
|
||||
let (event_type, marker) = match self {
|
||||
#[cfg(feature = "postgres")]
|
||||
Backend::Postgres => (trc::StoreEvent::PostgresqlError, "57014"),
|
||||
#[cfg(feature = "mysql")]
|
||||
Backend::MariaDb => (
|
||||
trc::StoreEvent::MysqlError,
|
||||
"Query execution was interrupted",
|
||||
),
|
||||
};
|
||||
let details = format!("{err:?}");
|
||||
|
||||
assert!(
|
||||
err.matches(trc::EventType::Store(event_type)) && details.contains(marker),
|
||||
"Expected a statement timeout, got: {details}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user