diff --git a/.gitignore b/.gitignore index 298bb47..797d531 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ .DS_Store Cargo.lock .idea/ -mdbx-sys/target/ \ No newline at end of file +mdbx-sys/target/ +docs/ +fuzz/corpus/ +fuzz/artifacts/ +fuzz/target/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 5885505..4e97ee5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,11 +46,31 @@ DUP_SORT/DUP_FIXED methods validate flags at runtime: - `require_dup_sort()` returns `MdbxError::RequiresDupSort` - `require_dup_fixed()` returns `MdbxError::RequiresDupFixed` -- `debug_assert_integer_key()` validates key length (4 or 8 bytes) in debug builds Methods requiring DUP_SORT: `first_dup`, `last_dup`, `next_dup`, `prev_dup`, `get_both`, `get_both_range` Methods requiring DUP_FIXED: `get_multiple`, `next_multiple`, `prev_multiple` +### Input Validation Model + +MDBX's C layer aborts the process (via `cASSERT`) on certain constraint +violations — notably INTEGER_KEY size mismatches and oversized keys/values. +These aborts cannot be caught. + +Our validation model (in `src/tx/assertions.rs`): + +- **Debug builds:** `debug_assert` checks catch constraint violations in + Rust before they reach FFI. This includes key/value size limits, INTEGER_KEY + length (must be 4 or 8 bytes), INTEGER_DUP length, and append ordering. +- **Release builds:** No checks are performed. Invalid input passes through + to MDBX, which may abort the process. +- **Benchmarks and fuzz targets:** MUST constrain inputs to valid ranges. + Do not feed arbitrary-length keys to INTEGER_KEY databases or oversized + keys/values to any database. The fuzz/bench harness is responsible for + generating valid input, not the library. + +This is intentional. The library trusts callers in release mode for +performance. The debug assertions exist to catch bugs during development. + ### Error Types - `MdbxError` - FFI/database errors (in `src/error.rs`) @@ -68,6 +88,7 @@ src/ codec.rs - TableObject trait tx/ mod.rs + assertions.rs - Debug assertions for key/value constraints cursor.rs - Cursor impl database.rs - Database struct sync.rs - Transaction impl @@ -76,14 +97,29 @@ src/ sys/ environment.rs - Environment impl tests/ - cursor.rs - Cursor tests - transaction.rs - Transaction tests - environment.rs - Environment tests + cursor.rs - Cursor tests + transaction.rs - Transaction tests + environment.rs - Environment tests + proptest_kv.rs - Property tests: key/value operations + proptest_cursor.rs - Property tests: cursor operations + proptest_dupsort.rs - Property tests: DUPSORT operations + proptest_dupfixed.rs - Property tests: DUPFIXED operations + proptest_iter.rs - Property tests: iterator operations + proptest_nested.rs - Property tests: nested transactions benches/ - cursor.rs - Cursor benchmarks + cursor.rs - Cursor read benchmarks + cursor_write.rs - Cursor write benchmarks (PARITY: evmdb) transaction.rs - Transaction benchmarks db_open.rs - Database open benchmarks + reserve.rs - Reserve vs put benchmarks + nested_txn.rs - Nested transaction benchmarks + concurrent.rs - Concurrency benchmarks (PARITY: evmdb) + scaling.rs - Scaling benchmarks (PARITY: evmdb) + deletion.rs - Deletion benchmarks + iter.rs - Iterator benchmarks utils.rs - Benchmark utilities +fuzz/ + fuzz_targets/ - cargo-fuzz targets (FFI/unsafe boundary hardening) ``` ## Testing @@ -109,3 +145,37 @@ This SHOULD be run alongside local tests and linting, especially for changes tha - Modify build configuration - Add new dependencies - Change platform-specific code + +## Parity Benchmarks + +Parity-tagged benchmarks (`// PARITY:`) have evmdb equivalents with +identical parameters for cross-project comparison. Both repos use +identical 32-byte binary keys and 128-byte binary values via +`parity_key`/`parity_value` in `benches/utils.rs`. + +```bash +# Run all parity benchmarks (native macOS or Linux) +cargo bench --bench cursor_write -- "cursor_write::put::sync|cursor_write::append::sync" +cargo bench --bench cursor -- "cursor::traverse::iter$" +cargo bench --bench concurrent -- "readers_no_writer|readers_one_writer" +cargo bench --bench scaling -- "sequential_get|random_get|full_iteration|append_ordered_put" + +# Cold-read parity benchmarks (Linux only — posix_fadvise is a no-op on macOS) +cargo bench --bench scaling -- "cold_random_get|cold_sequential_scan" +``` + +### Parity bench list + +| Bench | File | evmdb counterpart | +|-------|------|-------------------| +| `cursor_write::put::sync` | cursor_write.rs | write_put_100 | +| `cursor_write::append::sync` | cursor_write.rs | write_put_100_sorted | +| `cursor::traverse::iter` | cursor.rs | cursor_seek_first_iterate | +| `readers_no_writer` | concurrent.rs | readers_no_writer | +| `readers_one_writer` | concurrent.rs | readers_with_writer | +| `sequential_get` | scaling.rs | sequential_get | +| `random_get` | scaling.rs | random_get | +| `full_iteration` | scaling.rs | full_iteration | +| `append_ordered_put` | scaling.rs | put_sorted | +| `cold_random_get` | scaling.rs | cold_random_get | +| `cold_sequential_scan` | scaling.rs | cold_sequential_scan | diff --git a/Cargo.toml b/Cargo.toml index d9f8d5f..7b0f1d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "signet-libmdbx" description = "Idiomatic and safe MDBX wrapper" -version = "0.8.1" +version = "0.8.2" edition = "2024" rust-version = "1.92" license = "MIT OR Apache-2.0" @@ -45,6 +45,7 @@ tracing = "0.1.44" [dev-dependencies] criterion = "0.8.1" +libc = "0.2" proptest = "1" rand = "0.9.2" tempfile = "3.20.0" @@ -68,3 +69,23 @@ harness = false [[bench]] name = "deletion" harness = false + +[[bench]] +name = "cursor_write" +harness = false + +[[bench]] +name = "reserve" +harness = false + +[[bench]] +name = "nested_txn" +harness = false + +[[bench]] +name = "concurrent" +harness = false + +[[bench]] +name = "scaling" +harness = false diff --git a/README.md b/README.md index 90ee126..4371ecd 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,24 @@ NOTE: Most of the repo came from [lmdb-rs bindings]. - `sys` - Environment and transaction management. - `tx` - module contains transactions, cursors, and iterators +## Input Validation + +MDBX's C layer **aborts the process** on certain constraint violations, +such as passing an invalid key size to an `INTEGER_KEY` database or +exceeding the maximum key/value size. These aborts cannot be caught. + +This crate uses a **debug-only validation model**: + +- **Debug builds** (`cfg(debug_assertions)`): Rust-side assertions check + key/value constraints before they reach FFI. Violations panic with a + descriptive message, catching bugs during development. +- **Release builds**: No validation is performed. Invalid input passes + directly to MDBX for maximum performance. + +**Callers are responsible for ensuring inputs are valid in release +builds.** The debug assertions exist to catch bugs during development, +not to provide runtime safety guarantees. + ## Updating the libmdbx Version To update the libmdbx version you must clone it and copy the `dist/` folder in diff --git a/benches/concurrent.rs b/benches/concurrent.rs new file mode 100644 index 0000000..62cbb97 --- /dev/null +++ b/benches/concurrent.rs @@ -0,0 +1,183 @@ +#![allow(missing_docs, dead_code)] +mod utils; + +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; +use signet_libmdbx::{ObjectLength, WriteFlags}; +use std::{ + borrow::Cow, + hint::black_box, + sync::{Arc, Barrier}, + thread, +}; +use utils::{bench_key, bench_value, quick_config, setup_bench_env_with_max_readers}; + +const N_ROWS: u32 = 1_000; +const READER_COUNTS: &[usize] = &[1, 4, 8, 32, 128]; + +/// Max readers set high enough for the largest reader count plus criterion +/// overhead threads. +const MAX_READERS: u64 = 256; + +fn bench_n_readers_no_writer(c: &mut Criterion) { + let mut group = c.benchmark_group("concurrent::readers_no_writer"); + + for &n_readers in READER_COUNTS { + let (_dir, env) = setup_bench_env_with_max_readers(N_ROWS, Some(MAX_READERS)); + let env = Arc::new(env); + let keys: Arc> = Arc::new((0..N_ROWS).map(bench_key).collect()); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = env.begin_ro_sync().unwrap(); + txn.open_db(None).unwrap() + }; + + group.bench_with_input( + BenchmarkId::from_parameter(n_readers), + &n_readers, + |b, &n_readers| { + b.iter_batched( + || Arc::new(Barrier::new(n_readers + 1)), + |barrier| { + let handles: Vec<_> = (0..n_readers) + .map(|_| { + let env = Arc::clone(&env); + let keys = Arc::clone(&keys); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + let txn = env.begin_ro_sync().unwrap(); + barrier.wait(); + let mut total = 0usize; + for key in keys.iter() { + let val: Cow<'_, [u8]> = + txn.get(db.dbi(), key.as_slice()).unwrap().unwrap(); + total += val.len(); + } + black_box(total) + }) + }) + .collect(); + barrier.wait(); + handles.into_iter().for_each(|h| { + h.join().unwrap(); + }); + }, + BatchSize::PerIteration, + ) + }, + ); + } + group.finish(); +} + +fn bench_n_readers_one_writer(c: &mut Criterion) { + let mut group = c.benchmark_group("concurrent::readers_one_writer"); + + for &n_readers in READER_COUNTS { + let (_dir, env) = setup_bench_env_with_max_readers(N_ROWS, Some(MAX_READERS)); + let env = Arc::new(env); + let keys: Arc> = Arc::new((0..N_ROWS).map(bench_key).collect()); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = env.begin_ro_sync().unwrap(); + txn.open_db(None).unwrap() + }; + + group.bench_with_input( + BenchmarkId::from_parameter(n_readers), + &n_readers, + |b, &n_readers| { + b.iter_batched( + || Arc::new(Barrier::new(n_readers + 2)), + |barrier| { + // Spawn readers. + let reader_handles: Vec<_> = (0..n_readers) + .map(|_| { + let env = Arc::clone(&env); + let keys = Arc::clone(&keys); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + let txn = env.begin_ro_sync().unwrap(); + barrier.wait(); + let mut total = 0usize; + for key in keys.iter() { + let val: Cow<'_, [u8]> = + txn.get(db.dbi(), key.as_slice()).unwrap().unwrap(); + total += val.len(); + } + black_box(total) + }) + }) + .collect(); + + // Spawn one writer inserting one extra entry. + let writer = { + let env = Arc::clone(&env); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + let txn = env.begin_rw_sync().unwrap(); + txn.put( + db, + bench_key(N_ROWS + 1), + bench_value(N_ROWS + 1), + WriteFlags::empty(), + ) + .unwrap(); + txn.commit().unwrap(); + }) + }; + + barrier.wait(); + writer.join().unwrap(); + reader_handles.into_iter().for_each(|h| { + h.join().unwrap(); + }); + }, + BatchSize::PerIteration, + ) + }, + ); + } + group.finish(); +} + +/// Single-thread comparison: sync vs unsync transaction creation. +fn bench_single_thread_sync_vs_unsync(c: &mut Criterion) { + let (_dir, env) = setup_bench_env_with_max_readers(N_ROWS, None); + let keys: Arc> = Arc::new((0..N_ROWS).map(bench_key).collect()); + + c.bench_function("concurrent::single_thread::sync", |b| { + b.iter(|| { + let txn = env.begin_ro_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + let mut total = 0usize; + for key in keys.iter() { + total += *txn.get::(db.dbi(), key.as_slice()).unwrap().unwrap(); + } + black_box(total) + }) + }); + + c.bench_function("concurrent::single_thread::unsync", |b| { + b.iter(|| { + let txn = env.begin_ro_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + let mut total = 0usize; + for key in keys.iter() { + total += *txn.get::(db.dbi(), key.as_slice()).unwrap().unwrap(); + } + black_box(total) + }) + }); +} + +criterion_group! { + name = benches; + config = quick_config(); + targets = + bench_n_readers_no_writer, + bench_n_readers_one_writer, + bench_single_thread_sync_vs_unsync, +} + +criterion_main!(benches); diff --git a/benches/cursor.rs b/benches/cursor.rs index fc8ac96..20687af 100644 --- a/benches/cursor.rs +++ b/benches/cursor.rs @@ -8,7 +8,7 @@ use utils::*; /// Benchmark of iterator sequential read performance. fn bench_get_seq_iter(c: &mut Criterion) { - let n = 100; + let n = 1000; let (_dir, env) = setup_bench_db(n); let txn = create_ro_sync(&env); let db = txn.open_db(None).unwrap(); @@ -51,17 +51,17 @@ fn bench_get_seq_iter(c: &mut Criterion) { }); } -/// Benchmark of cursor sequential read performance. fn bench_get_seq_cursor(c: &mut Criterion) { - let n = 100; - let (_dir, env) = setup_bench_db(n); - let txn = create_ro_sync(&env); - let db = txn.open_db(None).unwrap(); - // Note: setup_bench_db creates a named database which adds metadata to the - // main database, so actual item count is n + 1 - let actual_items = n + 1; + let n = 1000; + let (_dir, env) = setup_bench_env(n); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = create_ro_sync(&env); + txn.open_db(None).unwrap() + }; c.bench_function("cursor::traverse::iter", |b| { b.iter(|| { + let txn = create_ro_sync(&env); let (i, count) = txn .cursor(db) .unwrap() @@ -70,13 +70,13 @@ fn bench_get_seq_cursor(c: &mut Criterion) { .fold((0, 0), |(i, count), (key, val)| (i + *key + *val, count + 1)); black_box(i); - assert_eq!(count, actual_items); + assert_eq!(count, n); }) }); } fn bench_get_seq_for_loop(c: &mut Criterion) { - let n = 100; + let n = 1000; let (_dir, env) = setup_bench_db(n); let txn = create_ro_sync(&env); let db = txn.open_db(None).unwrap(); @@ -103,7 +103,7 @@ fn bench_get_seq_for_loop(c: &mut Criterion) { /// Benchmark of iterator sequential read performance (single-thread). fn bench_get_seq_iter_single_thread(c: &mut Criterion) { - let n = 100; + let n = 1000; let (_dir, env) = setup_bench_db(n); let txn = create_ro_unsync(&env); let db = txn.open_db(None).unwrap(); @@ -146,17 +146,17 @@ fn bench_get_seq_iter_single_thread(c: &mut Criterion) { }); } -/// Benchmark of cursor sequential read performance (single-thread). fn bench_get_seq_cursor_single_thread(c: &mut Criterion) { - let n = 100; - let (_dir, env) = setup_bench_db(n); - let txn = create_ro_unsync(&env); - let db = txn.open_db(None).unwrap(); - // Note: setup_bench_db creates a named database which adds metadata to the - // main database, so actual item count is n + 1 - let actual_items = n + 1; + let n = 1000; + let (_dir, env) = setup_bench_env(n); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = create_ro_unsync(&env); + txn.open_db(None).unwrap() + }; c.bench_function("cursor::traverse::iter::single_thread", |b| { b.iter(|| { + let txn = create_ro_unsync(&env); let (i, count) = txn .cursor(db) .unwrap() @@ -165,13 +165,13 @@ fn bench_get_seq_cursor_single_thread(c: &mut Criterion) { .fold((0, 0), |(i, count), (key, val)| (i + *key + *val, count + 1)); black_box(i); - assert_eq!(count, actual_items); + assert_eq!(count, n); }) }); } fn bench_get_seq_for_loop_single_thread(c: &mut Criterion) { - let n = 100; + let n = 1000; let (_dir, env) = setup_bench_db(n); let txn = create_ro_unsync(&env); let db = txn.open_db(None).unwrap(); @@ -198,7 +198,7 @@ fn bench_get_seq_for_loop_single_thread(c: &mut Criterion) { /// Benchmark of raw MDBX sequential read performance (control). fn bench_get_seq_raw(c: &mut Criterion) { - let n = 100; + let n = 1000; let (_dir, env) = setup_bench_db(n); let mut key = MDBX_val { iov_len: 0, iov_base: ptr::null_mut() }; @@ -245,7 +245,7 @@ fn bench_get_seq_raw(c: &mut Criterion) { criterion_group! { name = benches; - config = Criterion::default(); + config = quick_config(); targets = bench_get_seq_iter, bench_get_seq_cursor, bench_get_seq_for_loop, bench_get_seq_raw, bench_get_seq_iter_single_thread, bench_get_seq_cursor_single_thread, bench_get_seq_for_loop_single_thread } diff --git a/benches/cursor_write.rs b/benches/cursor_write.rs new file mode 100644 index 0000000..feccf3f --- /dev/null +++ b/benches/cursor_write.rs @@ -0,0 +1,355 @@ +#![allow(missing_docs, dead_code)] +mod utils; + +use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; +use signet_libmdbx::{DatabaseFlags, WriteFlags}; +use std::cell::Cell; +use utils::*; + +const N: u32 = 100; +const DUPSORT_DB: &str = "dupsort_bench"; + +/// Set up a plain (no named sub-databases) environment with N key-value pairs. +fn setup_plain_env(n: u32) -> (tempfile::TempDir, signet_libmdbx::Environment) { + let dir = tempfile::tempdir().unwrap(); + let env = signet_libmdbx::Environment::builder().open(dir.path()).unwrap(); + { + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + for i in 0..n { + txn.put(db, get_key(i), get_data(i), WriteFlags::empty()).unwrap(); + } + txn.commit().unwrap(); + } + (dir, env) +} + +// PUT + +fn bench_cursor_put_sync(c: &mut Criterion) { + let items: Vec<([u8; 32], [u8; 128])> = + (0..N).map(|i| (bench_key(i), bench_value(i))).collect(); + let (_dir, env) = setup_bench_env(0); + + c.bench_function("cursor_write::put::sync", |b| { + b.iter_batched( + || { + let txn = create_rw_sync(&env); + let db = txn.open_db(None).unwrap(); + (txn, db) + }, + |(txn, db)| { + let mut cursor = txn.cursor(db).unwrap(); + for (key, data) in &items { + cursor.put(key.as_slice(), data.as_slice(), WriteFlags::empty()).unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); +} + +fn bench_cursor_put_unsync(c: &mut Criterion) { + let items: Vec<([u8; 32], [u8; 128])> = + (0..N).map(|i| (bench_key(i), bench_value(i))).collect(); + let (_dir, env) = setup_bench_env(0); + + c.bench_function("cursor_write::put::single_thread", |b| { + b.iter_batched( + || { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + (txn, db) + }, + |(txn, db)| { + let mut cursor = txn.cursor(db).unwrap(); + for (key, data) in &items { + cursor.put(key.as_slice(), data.as_slice(), WriteFlags::empty()).unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); +} + +// PUT + COMMIT (durable) + +fn bench_cursor_put_commit_durable(c: &mut Criterion) { + let (_dir, env) = setup_bench_env(0); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + txn.commit().unwrap(); + db + }; + // Advancing base counter — each iteration writes to fresh keys, matching + // XOR with a fixed value to produce unsorted order. + let base = Cell::new(0u32); + let xor_mask = 0xDEAD_BEEFu32; + + c.bench_function("cursor_write::put_commit::durable", |b| { + b.iter(|| { + let b_val = base.get(); + let txn = create_rw_unsync(&env); + let mut cursor = txn.cursor(db).unwrap(); + for i in 0..N { + let key = bench_key((b_val + i) ^ xor_mask); + let value = bench_value(b_val + i); + cursor.put(key.as_slice(), value.as_slice(), WriteFlags::empty()).unwrap(); + } + drop(cursor); + txn.commit().unwrap(); + base.set(b_val + N); + }) + }); +} + +fn bench_cursor_put_commit_nosync(c: &mut Criterion) { + let (_dir, env) = setup_bench_env_nosync(0); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + txn.commit().unwrap(); + db + }; + // Advancing base counter — each iteration writes to fresh keys, matching + // XOR with a fixed value to produce unsorted order. + let base = Cell::new(0u32); + let xor_mask = 0xDEAD_BEEFu32; + + c.bench_function("cursor_write::put_commit::nosync", |b| { + b.iter(|| { + let b_val = base.get(); + let txn = create_rw_unsync(&env); + let mut cursor = txn.cursor(db).unwrap(); + for i in 0..N { + let key = bench_key((b_val + i) ^ xor_mask); + let value = bench_value(b_val + i); + cursor.put(key.as_slice(), value.as_slice(), WriteFlags::empty()).unwrap(); + } + drop(cursor); + txn.commit().unwrap(); + base.set(b_val + N); + }) + }); +} + +// APPEND + COMMIT (durable) + +fn bench_cursor_append_commit_durable(c: &mut Criterion) { + let (_dir, env) = setup_bench_env(0); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + txn.commit().unwrap(); + db + }; + // Advancing base counter — each iteration appends to fresh sorted keys. + let base = Cell::new(0u32); + + c.bench_function("cursor_write::append_commit::durable", |b| { + b.iter(|| { + let b_val = base.get(); + let txn = create_rw_unsync(&env); + let mut cursor = txn.cursor(db).unwrap(); + for i in 0..N { + let key = bench_key(b_val + i); + let value = bench_value(b_val + i); + cursor.append(key.as_slice(), value.as_slice()).unwrap(); + } + drop(cursor); + txn.commit().unwrap(); + base.set(b_val + N); + }) + }); +} + +fn bench_cursor_append_commit_nosync(c: &mut Criterion) { + let (_dir, env) = setup_bench_env_nosync(0); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + txn.commit().unwrap(); + db + }; + // Advancing base counter — each iteration appends to fresh sorted keys. + let base = Cell::new(0u32); + + c.bench_function("cursor_write::append_commit::nosync", |b| { + b.iter(|| { + let b_val = base.get(); + let txn = create_rw_unsync(&env); + let mut cursor = txn.cursor(db).unwrap(); + for i in 0..N { + let key = bench_key(b_val + i); + let value = bench_value(b_val + i); + cursor.append(key.as_slice(), value.as_slice()).unwrap(); + } + drop(cursor); + txn.commit().unwrap(); + base.set(b_val + N); + }) + }); +} + +// DEL + +fn bench_cursor_del_sync(c: &mut Criterion) { + c.bench_function("cursor_write::del::sync", |b| { + b.iter_batched( + || setup_plain_env(N), + |(_dir, env)| { + let txn = create_rw_sync(&env); + let db = txn.open_db(None).unwrap(); + let mut cursor = txn.cursor(db).unwrap(); + cursor.first::<(), ()>().unwrap(); + while cursor.get_current::<(), ()>().unwrap().is_some() { + cursor.del().unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); +} + +fn bench_cursor_del_unsync(c: &mut Criterion) { + c.bench_function("cursor_write::del::single_thread", |b| { + b.iter_batched( + || setup_plain_env(N), + |(_dir, env)| { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + let mut cursor = txn.cursor(db).unwrap(); + cursor.first::<(), ()>().unwrap(); + while cursor.get_current::<(), ()>().unwrap().is_some() { + cursor.del().unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); +} + +// APPEND + +fn bench_cursor_append_sync(c: &mut Criterion) { + // Keys are big-endian u32 in first 4 bytes — inserting 0..N in order is + // already lexicographically sorted, satisfying the append precondition. + let items: Vec<([u8; 32], [u8; 128])> = + (0..N).map(|i| (bench_key(i), bench_value(i))).collect(); + let (_dir, env) = setup_bench_env(0); + + c.bench_function("cursor_write::append::sync", |b| { + b.iter_batched( + || { + let txn = create_rw_sync(&env); + let db = txn.open_db(None).unwrap(); + (txn, db) + }, + |(txn, db)| { + let mut cursor = txn.cursor(db).unwrap(); + for (key, data) in &items { + cursor.append(key.as_slice(), data.as_slice()).unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); +} + +fn bench_cursor_append_unsync(c: &mut Criterion) { + let items: Vec<([u8; 32], [u8; 128])> = + (0..N).map(|i| (bench_key(i), bench_value(i))).collect(); + let (_dir, env) = setup_bench_env(0); + + c.bench_function("cursor_write::append::single_thread", |b| { + b.iter_batched( + || { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + (txn, db) + }, + |(txn, db)| { + let mut cursor = txn.cursor(db).unwrap(); + for (key, data) in &items { + cursor.append(key.as_slice(), data.as_slice()).unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); +} + +// APPEND_DUP + +/// Set up a fresh environment with a DUPSORT database (no pre-existing data). +fn setup_dupsort_env() -> (tempfile::TempDir, signet_libmdbx::Environment) { + let dir = tempfile::tempdir().unwrap(); + let env = signet_libmdbx::Environment::builder().set_max_dbs(1).open(dir.path()).unwrap(); + // Create the named DUPSORT database so it exists for subsequent transactions. + { + let txn = env.begin_rw_unsync().unwrap(); + txn.create_db(Some(DUPSORT_DB), DatabaseFlags::DUP_SORT).unwrap(); + txn.commit().unwrap(); + } + (dir, env) +} + +fn bench_cursor_append_dup_sync(c: &mut Criterion) { + // One key, N duplicate values in sorted order. + let key = b"benchkey"; + let dups: Vec = (0..N).map(|i| format!("dup{i:05}")).collect(); + let (_dir, env) = setup_dupsort_env(); + + c.bench_function("cursor_write::append_dup::sync", |b| { + b.iter_batched( + || create_rw_sync(&env), + |txn| { + let db = txn.open_db(Some(DUPSORT_DB)).unwrap(); + let mut cursor = txn.cursor(db).unwrap(); + for dup in &dups { + cursor.append_dup(key, dup.as_bytes()).unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); +} + +fn bench_cursor_append_dup_unsync(c: &mut Criterion) { + let key = b"benchkey"; + let dups: Vec = (0..N).map(|i| format!("dup{i:05}")).collect(); + let (_dir, env) = setup_dupsort_env(); + + c.bench_function("cursor_write::append_dup::single_thread", |b| { + b.iter_batched( + || create_rw_unsync(&env), + |txn| { + let db = txn.open_db(Some(DUPSORT_DB)).unwrap(); + let mut cursor = txn.cursor(db).unwrap(); + for dup in &dups { + cursor.append_dup(key, dup.as_bytes()).unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); +} + +criterion_group! { + name = benches; + config = quick_config(); + targets = + bench_cursor_put_sync, bench_cursor_put_unsync, + bench_cursor_put_commit_durable, bench_cursor_put_commit_nosync, + bench_cursor_del_sync, bench_cursor_del_unsync, + bench_cursor_append_sync, bench_cursor_append_unsync, + bench_cursor_append_commit_durable, bench_cursor_append_commit_nosync, + bench_cursor_append_dup_sync, bench_cursor_append_dup_unsync, +} + +criterion_main!(benches); diff --git a/benches/db_open.rs b/benches/db_open.rs index 48217e4..5c5e97d 100644 --- a/benches/db_open.rs +++ b/benches/db_open.rs @@ -134,7 +134,7 @@ fn bench_open_db_no_cache_named(c: &mut Criterion) { criterion_group! { name = db_open; - config = Criterion::default(); + config = quick_config(); targets = bench_dbi_flags_ex_only, bench_dbi_open_only, diff --git a/benches/deletion.rs b/benches/deletion.rs index bb444a6..f6246b3 100644 --- a/benches/deletion.rs +++ b/benches/deletion.rs @@ -1,8 +1,10 @@ -#![allow(missing_docs)] +#![allow(missing_docs, dead_code)] +mod utils; use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; use signet_libmdbx::{DatabaseFlags, Environment, WriteFlags}; use tempfile::{TempDir, tempdir}; +use utils::quick_config; const VALUE_SIZE: usize = 100; const DB_NAME: &str = "deletion_bench"; @@ -73,7 +75,7 @@ fn bench_del_loop(c: &mut Criterion) { criterion_group! { name = benches; - config = Criterion::default(); + config = quick_config(); targets = bench_del_all_dups, bench_del_loop, } diff --git a/benches/iter.rs b/benches/iter.rs index a6ef52e..ce5d634 100644 --- a/benches/iter.rs +++ b/benches/iter.rs @@ -1,7 +1,7 @@ #![allow(missing_docs)] mod utils; -use crate::utils::{create_ro_sync, create_ro_unsync}; +use crate::utils::{create_ro_sync, create_ro_unsync, quick_config}; use criterion::{Criterion, criterion_group, criterion_main}; use signet_libmdbx::{DatabaseFlags, DupItem, Environment, WriteFlags}; use std::hint::black_box; @@ -120,7 +120,7 @@ fn bench_iter_simple_sync(c: &mut Criterion) { criterion_group! { name = benches; - config = Criterion::default(); + config = quick_config(); targets = bench_iter_dupfixed, bench_iter_simple, bench_iter_dupfixed_sync, bench_iter_simple_sync, } diff --git a/benches/nested_txn.rs b/benches/nested_txn.rs new file mode 100644 index 0000000..203e8e2 --- /dev/null +++ b/benches/nested_txn.rs @@ -0,0 +1,98 @@ +#![allow(missing_docs, dead_code)] +mod utils; + +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; +use signet_libmdbx::{Environment, WriteFlags}; +use tempfile::tempdir; +use utils::quick_config; + +fn setup_env() -> (tempfile::TempDir, Environment) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + (dir, env) +} + +/// Benchmark: create + commit a flat (non-nested) transaction as baseline. +fn bench_flat_baseline(c: &mut Criterion) { + let (_dir, env) = setup_env(); + + c.bench_function("nested_txn::flat_baseline", |b| { + b.iter_batched( + || (), + |()| { + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + txn.put(db, b"key", b"value", WriteFlags::empty()).unwrap(); + txn.commit().unwrap(); + }, + BatchSize::PerIteration, + ) + }); +} + +/// Benchmark: create + commit a nested transaction at depth N. +fn bench_nested_commit(c: &mut Criterion) { + let mut group = c.benchmark_group("nested_txn::commit"); + + for depth in [1usize, 2, 3] { + let (_dir, env) = setup_env(); + + group.bench_with_input(BenchmarkId::from_parameter(depth), &depth, |b, &depth| { + b.iter_batched( + || (), + |()| { + let root = env.begin_rw_sync().unwrap(); + // Build the nesting chain. Each nested txn is committed + // before the parent commits. + let mut parents = Vec::with_capacity(depth); + parents.push(root); + for _ in 1..depth { + let child = parents.last().unwrap().begin_nested_txn().unwrap(); + parents.push(child); + } + // Commit innermost to outermost. + for txn in parents.into_iter().rev() { + txn.commit().unwrap(); + } + }, + BatchSize::PerIteration, + ) + }); + } + group.finish(); +} + +/// Benchmark: write in a nested txn, commit child, verify visible in parent. +fn bench_nested_write_and_read(c: &mut Criterion) { + let (_dir, env) = setup_env(); + + c.bench_function("nested_txn::write_in_child_read_in_parent", |b| { + b.iter_batched( + || (), + |()| { + let parent = env.begin_rw_sync().unwrap(); + let child = parent.begin_nested_txn().unwrap(); + + let db = child.open_db(None).unwrap(); + child.put(db, b"nested_key", b"nested_val", WriteFlags::empty()).unwrap(); + child.commit().unwrap(); + + // Value should be visible to parent after child commit. + let db = parent.open_db(None).unwrap(); + let val: Option> = parent.get(db.dbi(), b"nested_key").unwrap(); + assert_eq!(val.as_deref(), Some(b"nested_val".as_slice())); + + parent.commit().unwrap(); + }, + BatchSize::PerIteration, + ) + }); +} + +criterion_group! { + name = benches; + config = quick_config(); + targets = bench_flat_baseline, bench_nested_commit, bench_nested_write_and_read, +} + +criterion_main!(benches); diff --git a/benches/reserve.rs b/benches/reserve.rs new file mode 100644 index 0000000..d2dd661 --- /dev/null +++ b/benches/reserve.rs @@ -0,0 +1,66 @@ +#![allow(missing_docs, dead_code)] +mod utils; + +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; +use signet_libmdbx::WriteFlags; +use utils::*; + +const VALUE_SIZES: &[usize] = &[64, 256, 1024, 4096]; +const KEY: &[u8] = b"benchkey"; + +fn bench_put(c: &mut Criterion) { + let mut group = c.benchmark_group("reserve::put"); + for &size in VALUE_SIZES { + let data = vec![0u8; size]; + let (_dir, env) = setup_bench_db(0); + + group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, _| { + b.iter_batched( + || { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + (txn, db) + }, + |(txn, db)| { + txn.put(db, KEY, data.as_slice(), WriteFlags::empty()).unwrap(); + }, + BatchSize::PerIteration, + ) + }); + } + group.finish(); +} + +fn bench_with_reservation(c: &mut Criterion) { + let mut group = c.benchmark_group("reserve::with_reservation"); + for &size in VALUE_SIZES { + let data = vec![0u8; size]; + let (_dir, env) = setup_bench_db(0); + + group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, _| { + b.iter_batched( + || { + let txn = create_rw_unsync(&env); + let db = txn.open_db(None).unwrap(); + (txn, db) + }, + |(txn, db)| { + txn.with_reservation(db, KEY, size, WriteFlags::empty(), |buf| { + buf.copy_from_slice(&data); + }) + .unwrap(); + }, + BatchSize::PerIteration, + ) + }); + } + group.finish(); +} + +criterion_group! { + name = benches; + config = quick_config(); + targets = bench_put, bench_with_reservation, +} + +criterion_main!(benches); diff --git a/benches/scaling.rs b/benches/scaling.rs new file mode 100644 index 0000000..37c086d --- /dev/null +++ b/benches/scaling.rs @@ -0,0 +1,236 @@ +#![allow(missing_docs, dead_code)] +mod utils; + +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; +use rand::{Rng, SeedableRng, prelude::SliceRandom, rngs::StdRng}; +use signet_libmdbx::{Environment, ObjectLength}; +use std::borrow::Cow; +use tempfile::tempdir; +use utils::{ + bench_key, bench_value_sized, is_bench_full, quick_config, setup_bench_env, + setup_bench_env_sized, +}; + +const COLD_N_ROWS: u32 = 1_000_000; +const COLD_LOOKUPS: u32 = 1_000; + +const ENTRY_COUNTS_FULL: &[u32] = &[100, 1_000, 10_000, 100_000]; +const ENTRY_COUNTS_QUICK: &[u32] = &[100, 1_000, 10_000]; +/// Value sizes for benchmarks. +const VALUE_SIZES: &[usize] = &[32, 128, 512]; + +fn entry_counts() -> &'static [u32] { + use std::sync::Once; + static WARN: Once = Once::new(); + if is_bench_full() { + ENTRY_COUNTS_FULL + } else { + WARN.call_once(|| { + eprintln!("NOTE: skipping 100K entry benchmarks (set BENCH_FULL=1 for full suite)"); + }); + ENTRY_COUNTS_QUICK + } +} + +fn bench_sequential_get(c: &mut Criterion) { + let mut group = c.benchmark_group("scaling::sequential_get"); + + for &size in VALUE_SIZES { + for &n in entry_counts() { + let (_dir, env) = setup_bench_env_sized(n, size); + let keys: Vec<[u8; 32]> = (0..n).map(bench_key).collect(); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = env.begin_ro_unsync().unwrap(); + txn.open_db(None).unwrap() + }; + + group.bench_with_input(BenchmarkId::new(format!("{size}B"), n), &n, |b, _| { + b.iter(|| { + let txn = env.begin_ro_unsync().unwrap(); + let mut total = 0usize; + for key in &keys { + let val: Cow<'_, [u8]> = + txn.get(db.dbi(), key.as_slice()).unwrap().unwrap(); + total += val.len(); + } + total + }) + }); + } + } + group.finish(); +} + +fn bench_random_get(c: &mut Criterion) { + let mut group = c.benchmark_group("scaling::random_get"); + + for &size in VALUE_SIZES { + for &n in entry_counts() { + let (_dir, env) = setup_bench_env_sized(n, size); + let mut keys: Vec<[u8; 32]> = (0..n).map(bench_key).collect(); + keys.shuffle(&mut StdRng::from_seed(Default::default())); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = env.begin_ro_unsync().unwrap(); + txn.open_db(None).unwrap() + }; + + group.bench_with_input(BenchmarkId::new(format!("{size}B"), n), &n, |b, _| { + b.iter(|| { + let txn = env.begin_ro_unsync().unwrap(); + let mut total = 0usize; + for key in &keys { + let val: Cow<'_, [u8]> = + txn.get(db.dbi(), key.as_slice()).unwrap().unwrap(); + total += val.len(); + } + total + }) + }); + } + } + group.finish(); +} + +fn bench_full_iteration(c: &mut Criterion) { + let mut group = c.benchmark_group("scaling::full_iteration"); + + for &size in VALUE_SIZES { + for &n in entry_counts() { + let (_dir, env) = setup_bench_env_sized(n, size); + // Open the db handle once — dbi is stable for the environment lifetime. + let db = { + let txn = env.begin_ro_unsync().unwrap(); + txn.open_db(None).unwrap() + }; + + group.bench_with_input(BenchmarkId::new(format!("{size}B"), n), &n, |b, _| { + b.iter(|| { + let txn = env.begin_ro_unsync().unwrap(); + let mut cursor = txn.cursor(db).unwrap(); + let mut count = 0usize; + while cursor.next::().unwrap().is_some() { + count += 1; + } + count + }) + }); + } + } + group.finish(); +} + +fn bench_append_ordered_put(c: &mut Criterion) { + let mut group = c.benchmark_group("scaling::append_ordered_put"); + + for &size in VALUE_SIZES { + for &n in entry_counts() { + let items: Vec<([u8; 32], Vec)> = + (0..n).map(|i| (bench_key(i), bench_value_sized(i, size))).collect(); + + group.bench_with_input(BenchmarkId::new(format!("{size}B"), n), &n, |b, _| { + b.iter_batched( + || { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + (dir, env) + }, + |(_dir, env)| { + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + for (key, data) in &items { + txn.append(db, key.as_slice(), data.as_slice()).unwrap(); + } + txn.commit().unwrap(); + }, + BatchSize::PerIteration, + ) + }); + } + } + group.finish(); +} + +/// Evicts OS page cache for the mdbx data file. Must be called while no +/// mmap exists on the file (i.e. after closing the environment) so the +/// kernel is free to drop the pages. `posix_fadvise` is advisory but +/// reliable when no active mappings pin the pages. +#[cfg(target_os = "linux")] +fn evict_os_cache(dir: &std::path::Path) { + use std::os::unix::io::AsRawFd; + let data_path = dir.join("mdbx.dat"); + let file = std::fs::File::open(&data_path).unwrap(); + // SAFETY: fd is valid from File::open. + let rc = unsafe { libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED) }; + assert_eq!(rc, 0, "posix_fadvise failed: {rc}"); + // File (and fd) dropped here. +} + +#[cfg(not(target_os = "linux"))] +fn evict_os_cache(_dir: &std::path::Path) { + // posix_fadvise not available on macOS; reads will be warm. +} + +fn bench_cold_random_get(c: &mut Criterion) { + let (dir, env) = setup_bench_env(COLD_N_ROWS); + // Drop the env so the mmap is unmapped before we evict cache. + drop(env); + + let mut rng = StdRng::seed_from_u64(42); + let indices: Vec = (0..COLD_LOOKUPS).map(|_| rng.random_range(0..COLD_N_ROWS)).collect(); + + c.bench_function("cold_random_get", |b| { + b.iter(|| { + evict_os_cache(dir.path()); + let env = Environment::builder().open(dir.path()).unwrap(); + let db = { + let txn = env.begin_ro_unsync().unwrap(); + txn.open_db(None).unwrap() + }; + for &i in &indices { + let key = bench_key(i); + let txn = env.begin_ro_unsync().unwrap(); + let val: Option> = txn.get(db.dbi(), key.as_slice()).unwrap(); + assert!(val.is_some()); + } + }); + }); +} + +fn bench_cold_sequential_scan(c: &mut Criterion) { + let (dir, env) = setup_bench_env(COLD_N_ROWS); + drop(env); + + c.bench_function("cold_sequential_scan", |b| { + b.iter(|| { + evict_os_cache(dir.path()); + let env = Environment::builder().open(dir.path()).unwrap(); + let db = { + let txn = env.begin_ro_unsync().unwrap(); + txn.open_db(None).unwrap() + }; + let txn = env.begin_ro_unsync().unwrap(); + let mut cursor = txn.cursor(db).unwrap(); + let mut count = 0u32; + while cursor.next::().unwrap().is_some() { + count += 1; + } + assert_eq!(count, COLD_N_ROWS); + }); + }); +} + +criterion_group! { + name = benches; + config = quick_config(); + targets = + bench_sequential_get, + bench_random_get, + bench_full_iteration, + bench_append_ordered_put, + bench_cold_random_get, + bench_cold_sequential_scan, +} + +criterion_main!(benches); diff --git a/benches/transaction.rs b/benches/transaction.rs index 6e3f868..2d392f2 100644 --- a/benches/transaction.rs +++ b/benches/transaction.rs @@ -93,7 +93,7 @@ fn bench_put_rand_raw(c: &mut Criterion) { let n = 100u32; let (_dir, env) = setup_bench_db(0); - let mut items: Vec<(String, String)> = (0..n).map(|n| (get_key(n), get_data(n))).collect(); + let mut items: Vec<(String, Vec)> = (0..n).map(|n| (get_key(n), get_data(n))).collect(); items.shuffle(&mut StdRng::from_seed(Default::default())); let dbi = create_ro_sync(&env).open_db(None).unwrap().dbi(); @@ -116,7 +116,7 @@ fn bench_put_rand_raw(c: &mut Criterion) { key_val.iov_len = key.len(); key_val.iov_base = key.as_bytes().as_ptr().cast_mut().cast(); data_val.iov_len = data.len(); - data_val.iov_base = data.as_bytes().as_ptr().cast_mut().cast(); + data_val.iov_base = data.as_ptr().cast_mut().cast(); i += mdbx_put(txn, dbi, &raw const key_val, &raw mut data_val, 0); } @@ -132,7 +132,7 @@ fn bench_put_rand_sync(c: &mut Criterion) { let n = 100u32; let (_dir, env) = setup_bench_db(0); - let mut items: Vec<(String, String)> = (0..n).map(|n| (get_key(n), get_data(n))).collect(); + let mut items: Vec<(String, Vec)> = (0..n).map(|n| (get_key(n), get_data(n))).collect(); items.shuffle(&mut StdRng::from_seed(Default::default())); c.bench_function("transaction::put::rand", |b| { @@ -156,7 +156,7 @@ fn bench_put_rand_unsync(c: &mut Criterion) { let n = 100u32; let (_dir, env) = setup_bench_db(0); - let mut items: Vec<(String, String)> = (0..n).map(|n| (get_key(n), get_data(n))).collect(); + let mut items: Vec<(String, Vec)> = (0..n).map(|n| (get_key(n), get_data(n))).collect(); items.shuffle(&mut StdRng::from_seed(Default::default())); c.bench_function("transaction::put::rand::single_thread", |b| { @@ -205,11 +205,61 @@ fn bench_tx_create_unsync(c: &mut Criterion) { }); } +// COMMIT + +const COMMIT_ENTRY_COUNTS: &[u32] = &[10, 100, 1_000, 10_000]; +const COMMIT_VALUE_SIZES: &[usize] = &[32, 128, 512]; + +fn make_commit_value(i: u32, size: usize) -> Vec { + let seed = format!("data{i:010}"); + seed.as_bytes().iter().copied().cycle().take(size).collect() +} + +/// Measures commit cost in isolation. The setup phase writes N entries of +/// a given value size (excluded from timing), then the timed phase calls +/// only `commit()`. +fn bench_commit_cost(c: &mut Criterion) { + let mut group = c.benchmark_group("transaction::commit"); + + for &size in COMMIT_VALUE_SIZES { + for &n in COMMIT_ENTRY_COUNTS { + let keys: Vec = (0..n).map(get_key).collect(); + let values: Vec> = (0..n).map(|i| make_commit_value(i, size)).collect(); + + group.bench_with_input( + criterion::BenchmarkId::new(format!("{size}B"), n), + &n, + |b, _| { + b.iter_batched( + || { + let dir = tempfile::tempdir().unwrap(); + let env = + signet_libmdbx::Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + for (key, value) in keys.iter().zip(values.iter()) { + txn.put(db, key, value, WriteFlags::empty()).unwrap(); + } + (dir, env, txn) + }, + |(_dir, _env, txn)| { + txn.commit().unwrap(); + }, + criterion::BatchSize::PerIteration, + ) + }, + ); + } + } + group.finish(); +} + criterion_group! { name = benches; - config = Criterion::default(); + config = quick_config(); targets = bench_get_rand_sync, bench_get_rand_raw, bench_get_rand_unsync, bench_put_rand_sync, bench_put_rand_raw, bench_put_rand_unsync, - bench_tx_create_raw, bench_tx_create_sync, bench_tx_create_unsync + bench_tx_create_raw, bench_tx_create_sync, bench_tx_create_unsync, + bench_commit_cost } criterion_main!(benches); diff --git a/benches/utils.rs b/benches/utils.rs index 681c40f..84e6297 100644 --- a/benches/utils.rs +++ b/benches/utils.rs @@ -1,25 +1,37 @@ //! Utility functions for benchmarks. #![allow(dead_code, unreachable_pub)] +use criterion::Criterion; use signet_libmdbx::{ - Environment, WriteFlags, + Environment, Mode, SyncMode, WriteFlags, ffi::{MDBX_TXN_RDONLY, MDBX_env, MDBX_txn, mdbx_txn_begin_ex}, tx::aliases::{RoTxSync, RoTxUnsync, RwTxSync, RwTxUnsync}, }; use std::ptr; use tempfile::{TempDir, tempdir}; +/// Returns true if `BENCH_FULL=1` is set in the environment. +pub fn is_bench_full() -> bool { + std::env::var("BENCH_FULL").is_ok_and(|v| v == "1") +} + +/// Quick criterion config: 10 samples, 1s warmup. +pub fn quick_config() -> Criterion { + Criterion::default().sample_size(10).warm_up_time(std::time::Duration::from_secs(1)) +} + /// Name of the named benchmark database. pub const NAMED_DB: &str = "named_benchmark_db"; /// Generate a DB key string for testing. pub fn get_key(n: u32) -> String { - format!("key{n}") + format!("key{n:028}") } -// Generate a DB data string for testing. -pub fn get_data(n: u32) -> String { - format!("data{n}") +/// Generate a 128-byte value for benchmarking. +pub fn get_data(n: u32) -> Vec { + let seed = format!("data{n:010}"); + seed.as_bytes().iter().copied().cycle().take(128).collect() } // Raw transaction utilities @@ -74,6 +86,91 @@ pub fn create_rw_unsync(env: &Environment) -> RwTxUnsync { env.begin_rw_unsync().unwrap() } +/// 32-byte key with i as big-endian u32 in the first 4 bytes, rest zeroed. +pub fn bench_key(i: u32) -> [u8; 32] { + let mut key = [0u8; 32]; + key[..4].copy_from_slice(&i.to_be_bytes()); + key +} + +/// 128-byte value with i as little-endian u32 in the first 4 bytes, rest zeroed. +pub fn bench_value(i: u32) -> [u8; 128] { + let mut value = [0u8; 128]; + value[..4].copy_from_slice(&i.to_le_bytes()); + value +} + +/// Variable-size value encoding. +/// Repeats the little-endian u32 bytes of `i` across `size` bytes. +pub fn bench_value_sized(i: u32, size: usize) -> Vec { + let bytes = i.to_le_bytes(); + (0..size).map(|j| bytes[j % 4]).collect() +} + +/// Set up environment with N rows (default DB only). +/// Uses the default durable sync mode. Values are 128 bytes. +pub fn setup_bench_env(n: u32) -> (TempDir, Environment) { + setup_bench_env_with_max_readers(n, None) +} + +/// Set up environment with N rows using variable-size encoding. +/// Uses the default durable sync mode. +pub fn setup_bench_env_sized(n: u32, value_size: usize) -> (TempDir, Environment) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + { + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + for i in 0..n { + txn.put(db, bench_key(i), bench_value_sized(i, value_size), WriteFlags::empty()) + .unwrap(); + } + txn.commit().unwrap(); + } + (dir, env) +} + +/// Set up environment with N rows using SafeNoSync mode (no fsync). +pub fn setup_bench_env_nosync(n: u32) -> (TempDir, Environment) { + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_flags(Mode::ReadWrite { sync_mode: SyncMode::SafeNoSync }.into()) + .open(dir.path()) + .unwrap(); + { + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + for i in 0..n { + txn.put(db, bench_key(i), bench_value(i), WriteFlags::empty()).unwrap(); + } + txn.commit().unwrap(); + } + (dir, env) +} + +/// Set up environment with N rows and a custom max reader count. +/// Pass [`None`] for the mdbx default (126). +pub fn setup_bench_env_with_max_readers( + n: u32, + max_readers: Option, +) -> (TempDir, Environment) { + let dir = tempdir().unwrap(); + let mut builder = Environment::builder(); + if let Some(max) = max_readers { + builder.set_max_readers(max); + } + let env = builder.open(dir.path()).unwrap(); + { + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + for i in 0..n { + txn.put(db, bench_key(i), bench_value(i), WriteFlags::empty()).unwrap(); + } + txn.commit().unwrap(); + } + (dir, env) +} + /// Create a temporary benchmark database with the specified number of rows. pub fn setup_bench_db(num_rows: u32) -> (TempDir, Environment) { let dir = tempdir().unwrap(); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..a9d510a --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "signet-libmdbx-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +signet-libmdbx = { path = ".." } +tempfile = "3" + +[workspace] +members = ["."] + +[[bin]] +name = "decode_cow" +path = "fuzz_targets/decode_cow.rs" +doc = false + +[[bin]] +name = "decode_array" +path = "fuzz_targets/decode_array.rs" +doc = false + +[[bin]] +name = "decode_object_length" +path = "fuzz_targets/decode_object_length.rs" +doc = false + +[[bin]] +name = "dirty_page_roundtrip" +path = "fuzz_targets/dirty_page_roundtrip.rs" +doc = false + +[[bin]] +name = "dupfixed_page_decode" +path = "fuzz_targets/dupfixed_page_decode.rs" +doc = false + +[[bin]] +name = "key_validation" +path = "fuzz_targets/key_validation.rs" +doc = false diff --git a/fuzz/fuzz_targets/decode_array.rs b/fuzz/fuzz_targets/decode_array.rs new file mode 100644 index 0000000..11d2426 --- /dev/null +++ b/fuzz/fuzz_targets/decode_array.rs @@ -0,0 +1,55 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use signet_libmdbx::{DatabaseFlags, Environment, MdbxError, ReadError, WriteFlags}; +use tempfile::tempdir; + +fuzz_target!(|data: &[u8]| { + // Need at least one byte for a key. + if data.is_empty() { + return; + } + + // Use first byte as key length (1..=16), rest is the value. + let key_len = ((data[0] as usize) % 16) + 1; + if data.len() < 1 + key_len { + return; + } + let key = &data[1..1 + key_len]; + let value = &data[1 + key_len..]; + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::empty()).unwrap(); + txn.put(db, key, value, WriteFlags::empty()).unwrap(); + txn.commit().unwrap(); + + let ro_txn = env.begin_ro_unsync().unwrap(); + let ro_db = ro_txn.open_db(None).unwrap(); + + // Attempt decoding as fixed-size arrays. Length mismatches must produce an + // error, never a panic. + let r4: Result, ReadError> = ro_txn.get(ro_db.dbi(), key); + let r8: Result, ReadError> = ro_txn.get(ro_db.dbi(), key); + let r16: Result, ReadError> = ro_txn.get(ro_db.dbi(), key); + let r32: Result, ReadError> = ro_txn.get(ro_db.dbi(), key); + + // Validate: correct length → Ok, wrong length → DecodeErrorLenDiff. + for (result, expected_len) in [ + (r4.map(|o| o.map(|a| a.len())), 4usize), + (r8.map(|o| o.map(|a| a.len())), 8), + (r16.map(|o| o.map(|a| a.len())), 16), + (r32.map(|o| o.map(|a| a.len())), 32), + ] { + match result { + Ok(Some(len)) => assert_eq!(len, expected_len), + Ok(None) => {} + Err(ReadError::Mdbx(MdbxError::DecodeErrorLenDiff)) => { + // Expected when value.len() != expected_len. + assert_ne!(value.len(), expected_len); + } + Err(e) => panic!("unexpected error: {e:?}"), + } + } +}); diff --git a/fuzz/fuzz_targets/decode_cow.rs b/fuzz/fuzz_targets/decode_cow.rs new file mode 100644 index 0000000..d198da7 --- /dev/null +++ b/fuzz/fuzz_targets/decode_cow.rs @@ -0,0 +1,44 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use signet_libmdbx::{DatabaseFlags, Environment, WriteFlags}; +use std::borrow::Cow; +use tempfile::tempdir; + +fuzz_target!(|data: &[u8]| { + // Need at least one byte to split key/value. + if data.is_empty() { + return; + } + + // Use first byte as split point for key vs value. + let split = (data[0] as usize).min(data.len().saturating_sub(1)); + let (key, value) = data[1..].split_at(split.min(data.len().saturating_sub(1))); + + // Keys must be non-empty for MDBX. + if key.is_empty() { + return; + } + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + + // Write in RW transaction, then read back as Cow (dirty page path). + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::empty()).unwrap(); + txn.put(db, key, value, WriteFlags::empty()).unwrap(); + + // Read while transaction is still open: data is on a dirty page, so + // Cow::decode_borrow should return Cow::Owned. + let readback: Option> = txn.get(db.dbi(), key).unwrap(); + let readback = readback.unwrap(); + assert_eq!(readback.as_ref(), value); + + txn.commit().unwrap(); + + // Read via RO transaction: data is on a clean page, so Cow should borrow. + let ro_txn = env.begin_ro_unsync().unwrap(); + let ro_db = ro_txn.open_db(None).unwrap(); + let clean: Option> = ro_txn.get(ro_db.dbi(), key).unwrap(); + let clean = clean.unwrap(); + assert_eq!(clean.as_ref(), value); +}); diff --git a/fuzz/fuzz_targets/decode_object_length.rs b/fuzz/fuzz_targets/decode_object_length.rs new file mode 100644 index 0000000..994df30 --- /dev/null +++ b/fuzz/fuzz_targets/decode_object_length.rs @@ -0,0 +1,35 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use signet_libmdbx::{DatabaseFlags, Environment, ObjectLength, WriteFlags}; +use tempfile::tempdir; + +fuzz_target!(|data: &[u8]| { + // Need at least 1 byte for the key. + if data.is_empty() { + return; + } + + // First byte: key length (1..=16). + let key_len = ((data[0] as usize) % 16) + 1; + if data.len() < 1 + key_len { + return; + } + let key = &data[1..1 + key_len]; + let value = &data[1 + key_len..]; + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::empty()).unwrap(); + txn.put(db, key, value, WriteFlags::empty()).unwrap(); + txn.commit().unwrap(); + + let ro_txn = env.begin_ro_unsync().unwrap(); + let ro_db = ro_txn.open_db(None).unwrap(); + + // ObjectLength must return the exact byte length of the stored value. + let len: Option = ro_txn.get(ro_db.dbi(), key).unwrap(); + let len = len.unwrap(); + assert_eq!(*len, value.len()); +}); diff --git a/fuzz/fuzz_targets/dirty_page_roundtrip.rs b/fuzz/fuzz_targets/dirty_page_roundtrip.rs new file mode 100644 index 0000000..fa565d3 --- /dev/null +++ b/fuzz/fuzz_targets/dirty_page_roundtrip.rs @@ -0,0 +1,66 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use signet_libmdbx::{DatabaseFlags, Environment, WriteFlags}; +use tempfile::tempdir; + +/// Near-page-boundary value sizes to probe is_dirty_raw behaviour. +const BIASED_SIZES: [usize; 4] = [4094, 4096, 4098, 0]; + +fuzz_target!(|data: &[u8]| { + if data.len() < 2 { + return; + } + + // First byte selects value-size bias; remaining bytes provide content. + let bias_idx = (data[0] as usize) % BIASED_SIZES.len(); + let biased_size = BIASED_SIZES[bias_idx]; + let content = &data[1..]; + + // Build value: if biased_size > 0, pad/trim content to that size. + let value: Vec = if biased_size > 0 { + let mut v = content.to_vec(); + v.resize(biased_size, 0xAB); + v + } else { + content.to_vec() + }; + + // Key is always the first 4 bytes of content (or padded). + let mut key = [0u8; 4]; + let copy_len = content.len().min(4); + key[..copy_len].copy_from_slice(&content[..copy_len]); + + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_geometry(signet_libmdbx::Geometry { + size: Some(0..(1024 * 1024 * 64)), + ..Default::default() + }) + .open(dir.path()) + .unwrap(); + + // Write in RW transaction; read back on dirty page. + // We use Vec to force a copy out of the transaction before commit. + let dirty_bytes: Vec = { + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::empty()).unwrap(); + txn.put(db, &key, &value, WriteFlags::empty()).unwrap(); + + // Read while dirty; Vec always copies, so no lifetime tie to txn. + let dirty: Option> = txn.get(db.dbi(), &key).unwrap(); + let dirty = dirty.unwrap(); + assert_eq!(dirty.as_slice(), value.as_slice()); + + txn.commit().unwrap(); + dirty + }; + + // Read via RO transaction: data now on a clean page. + let ro_txn = env.begin_ro_unsync().unwrap(); + let ro_db = ro_txn.open_db(None).unwrap(); + let clean: Option> = ro_txn.get(ro_db.dbi(), &key).unwrap(); + let clean = clean.unwrap(); + + // Both reads must agree on value content. + assert_eq!(dirty_bytes.as_slice(), clean.as_slice()); +}); diff --git a/fuzz/fuzz_targets/dupfixed_page_decode.rs b/fuzz/fuzz_targets/dupfixed_page_decode.rs new file mode 100644 index 0000000..c7a4248 --- /dev/null +++ b/fuzz/fuzz_targets/dupfixed_page_decode.rs @@ -0,0 +1,99 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use signet_libmdbx::{DatabaseFlags, Environment, WriteFlags}; +use tempfile::tempdir; + +fuzz_target!(|data: &[u8]| { + if data.len() < 2 { + return; + } + + // First byte: value size in the range 4..=64 (must be uniform across all + // values in a DUP_FIXED database). + let value_size = (data[0] as usize % 61) + 4; + // Second byte: number of values to insert, clamped to 1..=100. + let n_values = (data[1] as usize % 100) + 1; + let payload = &data[2..]; + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + + // Write phase: insert values, read back dirty page bytes, then commit. + let (dirty_len, inserted_count) = { + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT | DatabaseFlags::DUP_FIXED).unwrap(); + + // Build n_values distinct fixed-size values from the fuzz payload, + // padding or cycling as needed. We deduplicate before inserting: + // MDBX ignores exact duplicate key+value pairs silently. + let mut inserted: Vec> = Vec::with_capacity(n_values); + for i in 0..n_values { + let mut val = vec![0u8; value_size]; + // Fill from payload, cycling, then XOR with index for uniqueness. + for (j, byte) in val.iter_mut().enumerate() { + let src = if payload.is_empty() { + 0 + } else { + payload[(i * value_size + j) % payload.len()] + }; + *byte = src ^ ((i & 0xFF) as u8); + } + // Skip if we already have this exact value. + if !inserted.contains(&val) { + if txn.put(db, b"key", &val, WriteFlags::empty()).is_ok() { + inserted.push(val); + } + } + } + + if inserted.is_empty() { + return; + } + + // Read back via cursor while in the write transaction (dirty page). + // Use Vec so there is no lifetime tie to the transaction. + let dirty_len = { + let mut cursor = txn.cursor(db).unwrap(); + cursor.first::<(), ()>().unwrap(); + let dirty: Option> = cursor.get_multiple().unwrap(); + let page = dirty.unwrap(); + assert_eq!( + page.len() % value_size, + 0, + "dirty page length not a multiple of value_size" + ); + + // Reposition and read again; must be consistent. + cursor.first::<(), ()>().unwrap(); + let second: Option> = cursor.get_multiple().unwrap(); + assert_eq!( + page.as_slice(), + second.unwrap().as_slice(), + "inconsistent get_multiple reads" + ); + + page.len() + }; + + txn.commit().unwrap(); + (dirty_len, inserted.len()) + }; + + // Read via RO transaction (clean page) and verify consistency. + let ro_txn = env.begin_ro_unsync().unwrap(); + let ro_db = ro_txn.open_db(None).unwrap(); + let mut ro_cursor = ro_txn.cursor(ro_db).unwrap(); + ro_cursor.first::<(), ()>().unwrap(); + let clean: Option> = ro_cursor.get_multiple().unwrap(); + + let clean_len = clean.map(|p| { + assert_eq!(p.len() % value_size, 0, "clean page length not a multiple of value_size"); + p.len() + }); + + // Total items returned must match what we inserted (may span multiple + // pages; get_multiple only returns up to one page, so just verify + // divisibility and non-zero length). + assert!(clean_len.unwrap_or(0) > 0 || inserted_count == 0); + assert_eq!(dirty_len, clean_len.unwrap_or(0), "dirty vs clean page byte counts differ"); +}); diff --git a/fuzz/fuzz_targets/key_validation.rs b/fuzz/fuzz_targets/key_validation.rs new file mode 100644 index 0000000..d14654a --- /dev/null +++ b/fuzz/fuzz_targets/key_validation.rs @@ -0,0 +1,38 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use signet_libmdbx::{DatabaseFlags, Environment, WriteFlags}; +use tempfile::tempdir; + +fuzz_target!(|data: &[u8]| { + if data.is_empty() { + return; + } + + let dir = tempdir().unwrap(); + // Two named databases require set_max_dbs(2) on the environment. + let env = Environment::builder().set_max_dbs(2).open(dir.path()).unwrap(); + + let txn = env.begin_rw_unsync().unwrap(); + + // Database 1: default (no special flags). Accepts arbitrary byte keys. + let default_db = txn.create_db(None, DatabaseFlags::empty()).unwrap(); + + // Database 2: INTEGER_KEY. Requires 4- or 8-byte aligned keys. + let int_db = + txn.create_db(Some("intkeys"), DatabaseFlags::INTEGER_KEY | DatabaseFlags::CREATE).unwrap(); + + // Attempt put with the raw fuzz bytes as key. Should either succeed or + // return a typed error — never panic. + let _ = txn.put(default_db, data, b"value", WriteFlags::empty()); + + // INTEGER_KEY requires exactly 4 or 8 byte keys. MDBX aborts (not + // errors) on invalid sizes, so only feed valid-length keys to this db. + // We still fuzz the *content* of those keys. + if data.len() == 4 || data.len() == 8 { + let _ = txn.put(int_db, data, b"value", WriteFlags::empty()); + let _: signet_libmdbx::ReadResult>> = txn.get(int_db.dbi(), data); + } + + // Attempt get with fuzz bytes as key on the default database. + let _: signet_libmdbx::ReadResult>> = txn.get(default_db.dbi(), data); +}); diff --git a/src/lib.rs b/src/lib.rs index 6500322..be76c26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -128,21 +128,40 @@ //! //! See the [`TableObject`] docs for more examples. //! -//! # Debug assertions +//! # Input Validation //! -//! When compiled with debug assertions enabled (the default for -//! `cargo build`), this crate performs additional runtime checks to -//! catch common mistakes. +//! MDBX's C layer **aborts the process** on certain constraint violations, +//! such as passing a key that is not exactly 4 or 8 bytes to an +//! [`DatabaseFlags::INTEGER_KEY`] database, or exceeding the maximum +//! key/value size for the configured page size. These aborts cannot be +//! caught or recovered from. +//! +//! This crate uses a **debug-only validation model**: +//! +//! - **Debug builds** (`cfg(debug_assertions)`): Rust-side assertions +//! check key/value constraints before they reach FFI. Violations panic +//! with a descriptive message. This catches bugs during development. +//! - **Release builds**: No validation is performed. Invalid input passes +//! directly to MDBX for maximum performance. If the input violates +//! MDBX constraints, the process may abort. +//! +//! The following checks are performed in debug builds: //! //! 1. Key sizes are checked against the database's configured -//! `pagesize` and `DatabaseFlags` (e.g. `INTEGERKEY`). +//! `pagesize` and `DatabaseFlags` (e.g. `INTEGER_KEY`). //! 2. Value sizes are checked against the database's configured -//! `pagesize` and `DatabaseFlags` (e.g. `INTEGERDUP`). -//! 3. For `append` operations, it checks that the key being appended is +//! `pagesize` and `DatabaseFlags` (e.g. `INTEGER_DUP`). +//! 3. `INTEGER_KEY` databases require keys of exactly 4 or 8 bytes. +//! 4. `INTEGER_DUP` databases require values of exactly 4 or 8 bytes. +//! 5. For `append` operations, it checks that the key being appended is //! greater than the current last key using lexicographic comparison. //! This check is skipped for `REVERSE_KEY` and `REVERSE_DUP` databases -//! since they use different comparison semantics (comparing bytes from -//! end to beginning). +//! since they use different comparison semantics. +//! +//! **Callers are responsible for ensuring inputs are valid in release +//! builds.** This is a deliberate design choice: the library trusts its +//! callers in release mode for performance, and the debug assertions +//! exist to catch bugs during development. //! //! # Provenance //! diff --git a/src/tx/iter/mod.rs b/src/tx/iter/mod.rs index 57b3859..9eff7ee 100644 --- a/src/tx/iter/mod.rs +++ b/src/tx/iter/mod.rs @@ -30,15 +30,8 @@ //! # Dirty Page Handling //! //! In read-write transactions, database pages may be "dirty" (modified but -//! not yet committed). The behavior of `Cow<[u8]>` depends on the -//! `return-borrowed` feature: -//! -//! - **With `return-borrowed`**: Always returns `Cow::Borrowed`, even for -//! dirty pages. This is faster but the data may change if the transaction -//! modifies it later. -//! -//! - **Without `return-borrowed`** (default): Dirty pages are copied to -//! `Cow::Owned`. This is safer but allocates more. +//! not yet committed). When using `Cow<[u8]>`, dirty pages are copied to +//! `Cow::Owned` while clean pages are borrowed as `Cow::Borrowed`. //! //! # Example //! diff --git a/tests/proptest_cursor.rs b/tests/proptest_cursor.rs new file mode 100644 index 0000000..ca335e8 --- /dev/null +++ b/tests/proptest_cursor.rs @@ -0,0 +1,423 @@ +//! Property-based tests for cursor operations. +//! +//! Tests focus on both "does not panic" and correctness properties. Errors are +//! acceptable (e.g., `BadValSize`), panics are not. +#![allow(missing_docs)] + +use proptest::prelude::*; +use signet_libmdbx::{Environment, WriteFlags}; +use tempfile::tempdir; + +/// Strategy for generating byte vectors of various sizes (0 to 1KB). +fn arb_bytes() -> impl Strategy> { + prop::collection::vec(any::(), 0..1024) +} + +/// Strategy for keys that won't trigger MDBX assertion failures. +/// MDBX max key size is ~2022 bytes for 4KB pages. +fn arb_safe_key() -> impl Strategy> { + prop::collection::vec(any::(), 0..512) +} + +// ============================================================================= +// Cursor Operations - TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that cursor.set() with arbitrary key does not panic (V1). + #[test] + fn cursor_set_arbitrary_key_v1(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Add some data so cursor is positioned + txn.put(db, b"test_key", b"test_val", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + // set() with arbitrary key should return None or value, never panic + let result: signet_libmdbx::ReadResult>> = cursor.set(&key); + prop_assert!(result.is_ok()); + } + + /// Test that cursor.set_range() with arbitrary key does not panic (V1). + #[test] + fn cursor_set_range_arbitrary_key_v1(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Add some data + txn.put(db, b"aaa", b"val_a", WriteFlags::empty()).unwrap(); + txn.put(db, b"zzz", b"val_z", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + // set_range() with arbitrary key should not panic + let result: signet_libmdbx::ReadResult, Vec)>> = + cursor.set_range(&key); + prop_assert!(result.is_ok()); + } + + /// Test that cursor.set_key() with arbitrary key does not panic (V1). + #[test] + fn cursor_set_key_arbitrary_v1(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + txn.put(db, b"test", b"value", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + // set_key() should not panic + let result: signet_libmdbx::ReadResult, Vec)>> = + cursor.set_key(&key); + prop_assert!(result.is_ok()); + } +} + +// ============================================================================= +// Cursor Operations - TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that cursor.set() with arbitrary key does not panic (V2). + #[test] + fn cursor_set_arbitrary_key_v2(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + txn.put(db, b"test_key", b"test_val", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + let result: signet_libmdbx::ReadResult>> = cursor.set(&key); + prop_assert!(result.is_ok()); + } + + /// Test that cursor.set_range() with arbitrary key does not panic (V2). + #[test] + fn cursor_set_range_arbitrary_key_v2(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + txn.put(db, b"aaa", b"val_a", WriteFlags::empty()).unwrap(); + txn.put(db, b"zzz", b"val_z", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + let result: signet_libmdbx::ReadResult, Vec)>> = + cursor.set_range(&key); + prop_assert!(result.is_ok()); + } + + /// Test that cursor.set_key() with arbitrary key does not panic (V2). + #[test] + fn cursor_set_key_arbitrary_v2(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + txn.put(db, b"test", b"value", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + let result: signet_libmdbx::ReadResult, Vec)>> = + cursor.set_key(&key); + prop_assert!(result.is_ok()); + } +} + +// ============================================================================= +// Cursor Put Operations +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test cursor.put with arbitrary key/value does not panic (V1). + /// + /// Note: Uses constrained key sizes because MDBX aborts on very large keys + /// via cursor.put (assertion failure in cursor_put_checklen). + #[test] + fn cursor_put_arbitrary_v1(key in arb_safe_key(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + // cursor.put should not panic + let result = cursor.put(&key, &value, WriteFlags::empty()); + // Errors are fine (e.g., BadValSize), panics are not + let _ = result; + } + + /// Test cursor.put with arbitrary key/value does not panic (V2). + #[test] + fn cursor_put_arbitrary_v2(key in arb_safe_key(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + let result = cursor.put(&key, &value, WriteFlags::empty()); + let _ = result; + } +} + +// ============================================================================= +// Correctness: Cursor Set - TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that cursor.set returns the correct value when key exists (V1). + #[test] + fn cursor_set_correctness_v1(key in arb_safe_key(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Option> = cursor.set(&key).unwrap(); + prop_assert_eq!(retrieved, Some(value)); + } + } +} + +// ============================================================================= +// Correctness: Cursor Set - TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that cursor.set returns the correct value when key exists (V2). + #[test] + fn cursor_set_correctness_v2(key in arb_safe_key(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Option> = cursor.set(&key).unwrap(); + prop_assert_eq!(retrieved, Some(value)); + } + } +} + +// ============================================================================= +// New: Cursor set_lowerbound +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that set_lowerbound returns a key >= the search key when Some (V1). + #[test] + fn cursor_set_lowerbound_v1( + entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 1..10), + search_key in arb_safe_key(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + for (key, value) in &entries { + // Ignore errors (e.g. empty key issues) + let _ = txn.put(db, key, value, WriteFlags::empty()); + } + + let mut cursor = txn.cursor(db).unwrap(); + let result = cursor.set_lowerbound::, Vec>(&search_key); + prop_assert!(result.is_ok()); + + if let Some((_exact, returned_key, _val)) = result.unwrap() { + // The returned key must be >= the search key + prop_assert!(returned_key >= search_key); + } + } + + /// Test that set_lowerbound returns a key >= the search key when Some (V2). + #[test] + fn cursor_set_lowerbound_v2( + entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 1..10), + search_key in arb_safe_key(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + for (key, value) in &entries { + let _ = txn.put(db, key, value, WriteFlags::empty()); + } + + let mut cursor = txn.cursor(db).unwrap(); + let result = cursor.set_lowerbound::, Vec>(&search_key); + prop_assert!(result.is_ok()); + + if let Some((_exact, returned_key, _val)) = result.unwrap() { + prop_assert!(returned_key >= search_key); + } + } +} + +// ============================================================================= +// New: Cursor append sorted +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that appending sorted keys via cursor then iterating retrieves all in order (V1). + #[test] + fn cursor_append_sorted_v1( + raw_keys in prop::collection::vec(arb_safe_key(), 1..20), + ) { + // Filter out empty keys (MDBX allows empty keys but let's keep it simple) + let mut keys: Vec> = raw_keys.into_iter().filter(|k| !k.is_empty()).collect(); + prop_assume!(!keys.is_empty()); + + keys.sort(); + keys.dedup(); + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + // Append all keys in sorted order + for key in &keys { + cursor.append(key, b"v").unwrap(); + } + + // Iterate and verify all keys are present in order + let mut read_cursor = txn.cursor(db).unwrap(); + let retrieved: Vec> = read_cursor + .iter_start::, Vec>() + .unwrap() + .filter_map(Result::ok) + .map(|(k, _)| k) + .collect(); + + prop_assert_eq!(retrieved, keys); + } + + /// Test that appending sorted keys via cursor then iterating retrieves all in order (V2). + #[test] + fn cursor_append_sorted_v2( + raw_keys in prop::collection::vec(arb_safe_key(), 1..20), + ) { + let mut keys: Vec> = raw_keys.into_iter().filter(|k| !k.is_empty()).collect(); + prop_assume!(!keys.is_empty()); + + keys.sort(); + keys.dedup(); + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + for key in &keys { + cursor.append(key, b"v").unwrap(); + } + + let mut read_cursor = txn.cursor(db).unwrap(); + let retrieved: Vec> = read_cursor + .iter_start::, Vec>() + .unwrap() + .filter_map(Result::ok) + .map(|(k, _)| k) + .collect(); + + prop_assert_eq!(retrieved, keys); + } +} + +// ============================================================================= +// Cursor set_range correctness (migrated) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + #[test] + fn cursor_set_range_correctness_v1( + entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 2..10), + search_key in arb_safe_key(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + let mut inserted: Vec<(Vec, Vec)> = Vec::new(); + for (key, value) in &entries { + if txn.put(db, key, value, WriteFlags::empty()).is_ok() { + inserted.push((key.clone(), value.clone())); + } + } + prop_assume!(!inserted.is_empty()); + inserted.sort_by(|a, b| a.0.cmp(&b.0)); + inserted.dedup_by(|a, b| a.0 == b.0); + let expected = inserted.iter().find(|(k, _)| k >= &search_key).cloned(); + let mut cursor = txn.cursor(db).unwrap(); + let result: Option<(Vec, Vec)> = cursor.set_range(&search_key).unwrap(); + prop_assert_eq!(result, expected); + } + + #[test] + fn cursor_set_range_correctness_v2( + entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 2..10), + search_key in arb_safe_key(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + let mut inserted: Vec<(Vec, Vec)> = Vec::new(); + for (key, value) in &entries { + if txn.put(db, key, value, WriteFlags::empty()).is_ok() { + inserted.push((key.clone(), value.clone())); + } + } + prop_assume!(!inserted.is_empty()); + inserted.sort_by(|a, b| a.0.cmp(&b.0)); + inserted.dedup_by(|a, b| a.0 == b.0); + let expected = inserted.iter().find(|(k, _)| k >= &search_key).cloned(); + let mut cursor = txn.cursor(db).unwrap(); + let result: Option<(Vec, Vec)> = cursor.set_range(&search_key).unwrap(); + prop_assert_eq!(result, expected); + } +} diff --git a/tests/proptest_dupfixed.rs b/tests/proptest_dupfixed.rs new file mode 100644 index 0000000..9b0919a --- /dev/null +++ b/tests/proptest_dupfixed.rs @@ -0,0 +1,202 @@ +//! Property-based tests for DUP_FIXED operations. +//! +//! Tests focus on both "does not panic" and correctness properties. Errors are +//! acceptable (e.g., `BadValSize`), panics are not. +#![allow(missing_docs)] + +use proptest::prelude::*; +use signet_libmdbx::{DatabaseFlags, Environment, Geometry, WriteFlags}; +use tempfile::tempdir; + +// ============================================================================= +// Roundtrip: 8-byte values +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Test that put/iter_dupfixed_of roundtrips 8-byte values correctly (V2). + #[test] + fn dupfixed_roundtrip_8( + n_values in 1usize..20, + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT | DatabaseFlags::DUP_FIXED).unwrap(); + + // Insert n_values distinct 8-byte values under a single key. + let mut expected: Vec<[u8; 8]> = (0..n_values) + .map(|i| { + let mut v = [0u8; 8]; + v[0] = i as u8; + v[1] = (i >> 8) as u8; + v + }) + .collect(); + + for value in &expected { + txn.put(db, b"key", value.as_slice(), WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Vec<[u8; 8]> = cursor + .iter_dupfixed_of::<[u8; 8]>(b"key") + .unwrap() + .filter_map(Result::ok) + .collect(); + + expected.sort(); + let mut retrieved_sorted = retrieved; + retrieved_sorted.sort(); + prop_assert_eq!(retrieved_sorted, expected); + } + + /// Test that put/iter_dupfixed_of roundtrips 32-byte values correctly (V2). + #[test] + fn dupfixed_roundtrip_32( + n_values in 1usize..20, + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT | DatabaseFlags::DUP_FIXED).unwrap(); + + let mut expected: Vec<[u8; 32]> = (0..n_values) + .map(|i| { + let mut v = [0u8; 32]; + v[0] = i as u8; + v[1] = (i >> 8) as u8; + v + }) + .collect(); + + for value in &expected { + txn.put(db, b"key", value.as_slice(), WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Vec<[u8; 32]> = cursor + .iter_dupfixed_of::<[u8; 32]>(b"key") + .unwrap() + .filter_map(Result::ok) + .collect(); + + expected.sort(); + let mut retrieved_sorted = retrieved; + retrieved_sorted.sort(); + prop_assert_eq!(retrieved_sorted, expected); + } + + /// Test that put/iter_dupfixed_of roundtrips 100-byte values correctly (V2). + #[test] + fn dupfixed_roundtrip_100( + n_values in 1usize..20, + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT | DatabaseFlags::DUP_FIXED).unwrap(); + + let mut expected: Vec<[u8; 100]> = (0..n_values) + .map(|i| { + let mut v = [0u8; 100]; + v[0] = i as u8; + v[1] = (i >> 8) as u8; + v + }) + .collect(); + + for value in &expected { + txn.put(db, b"key", value.as_slice(), WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Vec<[u8; 100]> = cursor + .iter_dupfixed_of::<[u8; 100]>(b"key") + .unwrap() + .filter_map(Result::ok) + .collect(); + + expected.sort(); + let mut retrieved_sorted = retrieved; + retrieved_sorted.sort(); + prop_assert_eq!(retrieved_sorted, expected); + } +} + +// ============================================================================= +// Completeness: iter_dupfixed_start +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Test that iter_dupfixed_start yields exactly N items inserted under one key (V2). + #[test] + fn iter_dupfixed_start_completeness( + n_values in 1usize..100, + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT | DatabaseFlags::DUP_FIXED).unwrap(); + + // Insert n_values distinct 8-byte values under b"key" (3 bytes). + for i in 0..n_values { + let mut v = [0u8; 8]; + v[0] = i as u8; + v[1] = (i >> 8) as u8; + txn.put(db, b"key", v.as_slice(), WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + // Key type is [u8; 3] since "key" is 3 bytes, value type is [u8; 8]. + let count = cursor + .iter_dupfixed_start::<[u8; 3], [u8; 8]>() + .unwrap() + .filter_map(Result::ok) + .count(); + + prop_assert_eq!(count, n_values); + } +} + +// ============================================================================= +// Page spanning: large numbers of fixed-size values +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(16))] + + /// Test that all 64-byte values survive across page boundaries (V2). + #[test] + fn dupfixed_page_spanning( + n_values in 100usize..500, + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_geometry(Geometry { size: Some(0..(64 * 1024 * 1024)), ..Default::default() }) + .open(dir.path()) + .unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT | DatabaseFlags::DUP_FIXED).unwrap(); + + // Insert n_values distinct 64-byte values under a single key. + for i in 0..n_values { + let mut v = [0u8; 64]; + v[0] = i as u8; + v[1] = (i >> 8) as u8; + txn.put(db, b"key", v.as_slice(), WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Vec<[u8; 64]> = cursor + .iter_dupfixed_of::<[u8; 64]>(b"key") + .unwrap() + .filter_map(Result::ok) + .collect(); + + prop_assert_eq!(retrieved.len(), n_values); + } +} diff --git a/tests/proptest_dupsort.rs b/tests/proptest_dupsort.rs new file mode 100644 index 0000000..239a003 --- /dev/null +++ b/tests/proptest_dupsort.rs @@ -0,0 +1,515 @@ +//! Property-based tests for DUP_SORT operations and database name handling. +//! +//! Tests focus on both "does not panic" and correctness properties. Errors are +//! acceptable (e.g., `BadValSize`), panics are not. +#![allow(missing_docs)] + +use proptest::prelude::*; +use signet_libmdbx::{DatabaseFlags, Environment, WriteFlags}; +use tempfile::tempdir; + +/// Strategy for generating small byte vectors (0 to 64 bytes). +fn arb_small_bytes() -> impl Strategy> { + prop::collection::vec(any::(), 0..64) +} + +/// Strategy for valid database names (alphanumeric + underscore, 1-64 chars). +fn arb_db_name() -> impl Strategy { + "[a-zA-Z][a-zA-Z0-9_]{0,63}" +} + +// ============================================================================= +// Database Names +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that create_db with arbitrary valid names does not panic (V1). + #[test] + fn create_db_arbitrary_name_v1(name in arb_db_name()) { + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_max_dbs(16) + .open(dir.path()) + .unwrap(); + let txn = env.begin_rw_sync().unwrap(); + + // create_db should not panic, may return error for invalid names + let result = txn.create_db(Some(&name), DatabaseFlags::empty()); + // We accept both success and error, just no panic + let _ = result; + } + + /// Test that create_db with arbitrary valid names does not panic (V2). + #[test] + fn create_db_arbitrary_name_v2(name in arb_db_name()) { + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_max_dbs(16) + .open(dir.path()) + .unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + + let result = txn.create_db(Some(&name), DatabaseFlags::empty()); + let _ = result; + } +} + +// ============================================================================= +// DUP_SORT Operations +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that DUP_SORT put with multiple values does not panic (V1). + #[test] + fn dupsort_put_multiple_values_v1( + key in arb_small_bytes(), + values in prop::collection::vec(arb_small_bytes(), 1..10), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for value in &values { + // Should not panic + let result = txn.put(db, &key, value, WriteFlags::empty()); + // Errors are acceptable, panics are not + let _ = result; + } + } + + /// Test that DUP_SORT put with multiple values does not panic (V2). + #[test] + fn dupsort_put_multiple_values_v2( + key in arb_small_bytes(), + values in prop::collection::vec(arb_small_bytes(), 1..10), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for value in &values { + let result = txn.put(db, &key, value, WriteFlags::empty()); + let _ = result; + } + } + + /// Test cursor get_both with arbitrary key/value does not panic (V1). + #[test] + fn cursor_get_both_arbitrary_v1( + search_key in arb_small_bytes(), + search_value in arb_small_bytes(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + // Add some data + txn.put(db, b"key1", b"val1", WriteFlags::empty()).unwrap(); + txn.put(db, b"key1", b"val2", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + // get_both should not panic + let result: signet_libmdbx::ReadResult>> = + cursor.get_both(&search_key, &search_value); + prop_assert!(result.is_ok()); + } + + /// Test cursor get_both_range with arbitrary key/value does not panic (V1). + #[test] + fn cursor_get_both_range_arbitrary_v1( + search_key in arb_small_bytes(), + search_value in arb_small_bytes(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + txn.put(db, b"key1", b"val1", WriteFlags::empty()).unwrap(); + txn.put(db, b"key1", b"val2", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + // get_both_range should not panic + let result: signet_libmdbx::ReadResult>> = + cursor.get_both_range(&search_key, &search_value); + prop_assert!(result.is_ok()); + } + + /// Test cursor get_both with arbitrary key/value does not panic (V2). + #[test] + fn cursor_get_both_arbitrary_v2( + search_key in arb_small_bytes(), + search_value in arb_small_bytes(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + txn.put(db, b"key1", b"val1", WriteFlags::empty()).unwrap(); + txn.put(db, b"key1", b"val2", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + let result: signet_libmdbx::ReadResult>> = + cursor.get_both(&search_key, &search_value); + prop_assert!(result.is_ok()); + } + + /// Test cursor get_both_range with arbitrary key/value does not panic (V2). + #[test] + fn cursor_get_both_range_arbitrary_v2( + search_key in arb_small_bytes(), + search_value in arb_small_bytes(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + txn.put(db, b"key1", b"val1", WriteFlags::empty()).unwrap(); + txn.put(db, b"key1", b"val2", WriteFlags::empty()).unwrap(); + + let mut cursor = txn.cursor(db).unwrap(); + + let result: signet_libmdbx::ReadResult>> = + cursor.get_both_range(&search_key, &search_value); + prop_assert!(result.is_ok()); + } +} + +// ============================================================================= +// Correctness: DUP_SORT Values - TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that all unique DUP_SORT values are retrievable via iter_dup_of (V1). + #[test] + fn dupsort_values_correctness_v1( + key in arb_small_bytes(), + values in prop::collection::vec(arb_small_bytes(), 1..10), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + // Insert all values + let mut inserted: Vec> = Vec::new(); + for value in &values { + if txn.put(db, &key, value, WriteFlags::empty()).is_ok() + && !inserted.contains(value) + { + inserted.push(value.clone()); + } + } + + // Skip if nothing was inserted + prop_assume!(!inserted.is_empty()); + + // Retrieve all values via iter_dup_of (yields just values, not (key, value)) + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Vec> = + cursor.iter_dup_of::>(&key).unwrap().filter_map(Result::ok).collect(); + + // All inserted values should be retrieved (order is sorted by MDBX) + inserted.sort(); + let mut retrieved_sorted = retrieved.clone(); + retrieved_sorted.sort(); + prop_assert_eq!(inserted, retrieved_sorted); + } +} + +// ============================================================================= +// Correctness: DUP_SORT Values - TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that all unique DUP_SORT values are retrievable via iter_dup_of (V2). + #[test] + fn dupsort_values_correctness_v2( + key in arb_small_bytes(), + values in prop::collection::vec(arb_small_bytes(), 1..10), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + let mut inserted: Vec> = Vec::new(); + for value in &values { + if txn.put(db, &key, value, WriteFlags::empty()).is_ok() + && !inserted.contains(value) + { + inserted.push(value.clone()); + } + } + + prop_assume!(!inserted.is_empty()); + + // iter_dup_of yields just values, not (key, value) + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Vec> = + cursor.iter_dup_of::>(&key).unwrap().filter_map(Result::ok).collect(); + + inserted.sort(); + let mut retrieved_sorted = retrieved.clone(); + retrieved_sorted.sort(); + prop_assert_eq!(inserted, retrieved_sorted); + } +} + +// ============================================================================= +// New: Delete specific dup +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that del with a specific value removes only that dup (V1). + #[test] + fn del_specific_dup_v1( + key in arb_small_bytes(), + values in prop::collection::vec(arb_small_bytes(), 2..8), + ) { + // Need at least 2 distinct non-empty values and a non-empty key + prop_assume!(!key.is_empty()); + let mut unique: Vec> = values + .into_iter() + .filter(|v| !v.is_empty()) + .collect::>() + .into_iter() + .collect(); + prop_assume!(unique.len() >= 2); + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + // Insert all unique values + for value in &unique { + txn.put(db, &key, value, WriteFlags::empty()).unwrap(); + } + + // Delete the first value specifically + let to_delete = unique.remove(0); + let deleted = txn.del(db, &key, Some(to_delete.as_slice())).unwrap(); + prop_assert!(deleted); + + // Retrieve remaining values + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Vec> = + cursor.iter_dup_of::>(&key).unwrap().filter_map(Result::ok).collect(); + + // The deleted value should not be present; the rest should be + prop_assert!(!retrieved.contains(&to_delete)); + unique.sort(); + let mut retrieved_sorted = retrieved; + retrieved_sorted.sort(); + prop_assert_eq!(retrieved_sorted, unique); + } + + /// Test that del with a specific value removes only that dup (V2). + #[test] + fn del_specific_dup_v2( + key in arb_small_bytes(), + values in prop::collection::vec(arb_small_bytes(), 2..8), + ) { + prop_assume!(!key.is_empty()); + let mut unique: Vec> = values + .into_iter() + .filter(|v| !v.is_empty()) + .collect::>() + .into_iter() + .collect(); + prop_assume!(unique.len() >= 2); + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for value in &unique { + txn.put(db, &key, value, WriteFlags::empty()).unwrap(); + } + + let to_delete = unique.remove(0); + let deleted = txn.del(db, &key, Some(to_delete.as_slice())).unwrap(); + prop_assert!(deleted); + + let mut cursor = txn.cursor(db).unwrap(); + let retrieved: Vec> = + cursor.iter_dup_of::>(&key).unwrap().filter_map(Result::ok).collect(); + + prop_assert!(!retrieved.contains(&to_delete)); + unique.sort(); + let mut retrieved_sorted = retrieved; + retrieved_sorted.sort(); + prop_assert_eq!(retrieved_sorted, unique); + } +} + +// ============================================================================= +// New: Delete all dups +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that del with None removes all dup values for the key (V1). + #[test] + fn del_all_dups_v1( + key in arb_small_bytes(), + values in prop::collection::vec(arb_small_bytes(), 1..8), + ) { + prop_assume!(!key.is_empty()); + let unique: Vec> = values + .into_iter() + .filter(|v| !v.is_empty()) + .collect::>() + .into_iter() + .collect(); + prop_assume!(!unique.is_empty()); + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for value in &unique { + txn.put(db, &key, value, WriteFlags::empty()).unwrap(); + } + + // del with None deletes ALL dups for this key + let deleted = txn.del(db, &key, None).unwrap(); + prop_assert!(deleted); + + // After deletion, get should return None + let result: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert!(result.is_none()); + } + + /// Test that del with None removes all dup values for the key (V2). + #[test] + fn del_all_dups_v2( + key in arb_small_bytes(), + values in prop::collection::vec(arb_small_bytes(), 1..8), + ) { + prop_assume!(!key.is_empty()); + let unique: Vec> = values + .into_iter() + .filter(|v| !v.is_empty()) + .collect::>() + .into_iter() + .collect(); + prop_assume!(!unique.is_empty()); + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for value in &unique { + txn.put(db, &key, value, WriteFlags::empty()).unwrap(); + } + + let deleted = txn.del(db, &key, None).unwrap(); + prop_assert!(deleted); + + let result: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert!(result.is_none()); + } +} + +// ============================================================================= +// New: iter_dup completeness +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Test that iter_dup_of retrieves all inserted values for each key (V1). + #[test] + fn iter_dup_completeness_v1( + n_keys in 1usize..5, + m_values in 1usize..6, + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + // Insert N keys with M values each (using deterministic byte sequences) + for k in 0..n_keys { + let key = vec![k as u8]; + for v in 0..m_values { + let value = vec![v as u8]; + txn.put(db, &key, &value, WriteFlags::empty()).unwrap(); + } + } + + // Verify each key has exactly M values via iter_dup_of + let mut cursor = txn.cursor(db).unwrap(); + for k in 0..n_keys { + let key = vec![k as u8]; + let retrieved: Vec> = cursor + .iter_dup_of::>(&key) + .unwrap() + .filter_map(Result::ok) + .collect(); + prop_assert_eq!(retrieved.len(), m_values); + + // Values should be in order 0..m_values + let expected: Vec> = (0..m_values).map(|v| vec![v as u8]).collect(); + prop_assert_eq!(retrieved, expected); + } + } + + /// Test that iter_dup_of retrieves all inserted values for each key (V2). + #[test] + fn iter_dup_completeness_v2( + n_keys in 1usize..5, + m_values in 1usize..6, + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for k in 0..n_keys { + let key = vec![k as u8]; + for v in 0..m_values { + let value = vec![v as u8]; + txn.put(db, &key, &value, WriteFlags::empty()).unwrap(); + } + } + + let mut cursor = txn.cursor(db).unwrap(); + for k in 0..n_keys { + let key = vec![k as u8]; + let retrieved: Vec> = cursor + .iter_dup_of::>(&key) + .unwrap() + .filter_map(Result::ok) + .collect(); + prop_assert_eq!(retrieved.len(), m_values); + + let expected: Vec> = (0..m_values).map(|v| vec![v as u8]).collect(); + prop_assert_eq!(retrieved, expected); + } + } +} diff --git a/tests/proptest_inputs.proptest-regressions b/tests/proptest_inputs.proptest-regressions deleted file mode 100644 index d148876..0000000 --- a/tests/proptest_inputs.proptest-regressions +++ /dev/null @@ -1,7 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc dc36b6543362c37ff0fe3b0fd4cda292813822e1d326c4b7a5b06419c4ee1369 # shrinks to value = [] diff --git a/tests/proptest_inputs.rs b/tests/proptest_inputs.rs deleted file mode 100644 index 0644b93..0000000 --- a/tests/proptest_inputs.rs +++ /dev/null @@ -1,1152 +0,0 @@ -//! Property-based tests to ensure arbitrary inputs do not cause panics. -//! -//! These tests focus on "does not panic" rather than correctness. Errors are -//! acceptable (e.g., `BadValSize`), panics are not. -#![allow(missing_docs)] - -use proptest::prelude::*; -use signet_libmdbx::{DatabaseFlags, Environment, WriteFlags}; -use tempfile::tempdir; - -/// Strategy for generating byte vectors of various sizes (0 to 1KB). -fn arb_bytes() -> impl Strategy> { - prop::collection::vec(any::(), 0..1024) -} - -/// Strategy for generating small byte vectors (0 to 64 bytes). -fn arb_small_bytes() -> impl Strategy> { - prop::collection::vec(any::(), 0..64) -} - -/// Strategy for valid database names (alphanumeric + underscore, 1-64 chars). -fn arb_db_name() -> impl Strategy { - "[a-zA-Z][a-zA-Z0-9_]{0,63}" -} - -// ============================================================================= -// Key/Value Operations - TxSync (V1) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that put/get with arbitrary key/value does not panic (V1). - #[test] - fn put_get_arbitrary_kv_v1(key in arb_bytes(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Should not panic - may return error for invalid sizes - let put_result = txn.put(db, &key, &value, WriteFlags::empty()); - - // If put succeeded, get should not panic - if put_result.is_ok() { - let _: Option> = txn.get(db.dbi(), &key).unwrap(); - } - } - - /// Test that del with nonexistent arbitrary key does not panic (V1). - #[test] - fn del_nonexistent_key_v1(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Delete on nonexistent key should return Ok(false), not panic - let result = txn.del(db, &key, None); - prop_assert!(result.is_ok()); - prop_assert!(!result.unwrap()); - } - - /// Test that get with arbitrary key on empty db does not panic (V1). - #[test] - fn get_arbitrary_key_empty_db_v1(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_ro_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Get on nonexistent key should return Ok(None), not panic - let result: signet_libmdbx::ReadResult>> = txn.get(db.dbi(), &key); - prop_assert!(result.is_ok()); - prop_assert!(result.unwrap().is_none()); - } -} - -// ============================================================================= -// Key/Value Operations - TxUnsync (V2) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that put/get with arbitrary key/value does not panic (V2). - #[test] - fn put_get_arbitrary_kv_v2(key in arb_bytes(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Should not panic - may return error for invalid sizes - let put_result = txn.put(db, &key, &value, WriteFlags::empty()); - - // If put succeeded, get should not panic - if put_result.is_ok() { - let _: Option> = txn.get(db.dbi(), &key).unwrap(); - } - } - - /// Test that del with nonexistent arbitrary key does not panic (V2). - #[test] - fn del_nonexistent_key_v2(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Delete on nonexistent key should return Ok(false), not panic - let result = txn.del(db, &key, None); - prop_assert!(result.is_ok()); - prop_assert!(!result.unwrap()); - } - - /// Test that get with arbitrary key on empty db does not panic (V2). - #[test] - fn get_arbitrary_key_empty_db_v2(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_ro_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Get on nonexistent key should return Ok(None), not panic - let result: signet_libmdbx::ReadResult>> = txn.get(db.dbi(), &key); - prop_assert!(result.is_ok()); - prop_assert!(result.unwrap().is_none()); - } -} - -// ============================================================================= -// Cursor Operations -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that cursor.set() with arbitrary key does not panic (V1). - #[test] - fn cursor_set_arbitrary_key_v1(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Add some data so cursor is positioned - txn.put(db, b"test_key", b"test_val", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - // set() with arbitrary key should return None or value, never panic - let result: signet_libmdbx::ReadResult>> = cursor.set(&key); - prop_assert!(result.is_ok()); - } - - /// Test that cursor.set_range() with arbitrary key does not panic (V1). - #[test] - fn cursor_set_range_arbitrary_key_v1(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Add some data - txn.put(db, b"aaa", b"val_a", WriteFlags::empty()).unwrap(); - txn.put(db, b"zzz", b"val_z", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - // set_range() with arbitrary key should not panic - let result: signet_libmdbx::ReadResult, Vec)>> = - cursor.set_range(&key); - prop_assert!(result.is_ok()); - } - - /// Test that cursor.set_key() with arbitrary key does not panic (V1). - #[test] - fn cursor_set_key_arbitrary_v1(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - txn.put(db, b"test", b"value", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - // set_key() should not panic - let result: signet_libmdbx::ReadResult, Vec)>> = - cursor.set_key(&key); - prop_assert!(result.is_ok()); - } - - /// Test that cursor.set() with arbitrary key does not panic (V2). - #[test] - fn cursor_set_arbitrary_key_v2(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - txn.put(db, b"test_key", b"test_val", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - let result: signet_libmdbx::ReadResult>> = cursor.set(&key); - prop_assert!(result.is_ok()); - } - - /// Test that cursor.set_range() with arbitrary key does not panic (V2). - #[test] - fn cursor_set_range_arbitrary_key_v2(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - txn.put(db, b"aaa", b"val_a", WriteFlags::empty()).unwrap(); - txn.put(db, b"zzz", b"val_z", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - let result: signet_libmdbx::ReadResult, Vec)>> = - cursor.set_range(&key); - prop_assert!(result.is_ok()); - } - - /// Test that cursor.set_key() with arbitrary key does not panic (V2). - #[test] - fn cursor_set_key_arbitrary_v2(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - txn.put(db, b"test", b"value", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - let result: signet_libmdbx::ReadResult, Vec)>> = - cursor.set_key(&key); - prop_assert!(result.is_ok()); - } -} - -// ============================================================================= -// Database Names -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test that create_db with arbitrary valid names does not panic (V1). - #[test] - fn create_db_arbitrary_name_v1(name in arb_db_name()) { - let dir = tempdir().unwrap(); - let env = Environment::builder() - .set_max_dbs(16) - .open(dir.path()) - .unwrap(); - let txn = env.begin_rw_sync().unwrap(); - - // create_db should not panic, may return error for invalid names - let result = txn.create_db(Some(&name), DatabaseFlags::empty()); - // We accept both success and error, just no panic - let _ = result; - } - - /// Test that create_db with arbitrary valid names does not panic (V2). - #[test] - fn create_db_arbitrary_name_v2(name in arb_db_name()) { - let dir = tempdir().unwrap(); - let env = Environment::builder() - .set_max_dbs(16) - .open(dir.path()) - .unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - - let result = txn.create_db(Some(&name), DatabaseFlags::empty()); - let _ = result; - } -} - -// ============================================================================= -// DUP_SORT Operations -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test that DUP_SORT put with multiple values does not panic (V1). - #[test] - fn dupsort_put_multiple_values_v1( - key in arb_small_bytes(), - values in prop::collection::vec(arb_small_bytes(), 1..10), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - for value in &values { - // Should not panic - let result = txn.put(db, &key, value, WriteFlags::empty()); - // Errors are acceptable, panics are not - let _ = result; - } - } - - /// Test that DUP_SORT put with multiple values does not panic (V2). - #[test] - fn dupsort_put_multiple_values_v2( - key in arb_small_bytes(), - values in prop::collection::vec(arb_small_bytes(), 1..10), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - for value in &values { - let result = txn.put(db, &key, value, WriteFlags::empty()); - let _ = result; - } - } - - /// Test cursor get_both with arbitrary key/value does not panic (V1). - #[test] - fn cursor_get_both_arbitrary_v1( - search_key in arb_small_bytes(), - search_value in arb_small_bytes(), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - // Add some data - txn.put(db, b"key1", b"val1", WriteFlags::empty()).unwrap(); - txn.put(db, b"key1", b"val2", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - // get_both should not panic - let result: signet_libmdbx::ReadResult>> = - cursor.get_both(&search_key, &search_value); - prop_assert!(result.is_ok()); - } - - /// Test cursor get_both_range with arbitrary key/value does not panic (V1). - #[test] - fn cursor_get_both_range_arbitrary_v1( - search_key in arb_small_bytes(), - search_value in arb_small_bytes(), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - txn.put(db, b"key1", b"val1", WriteFlags::empty()).unwrap(); - txn.put(db, b"key1", b"val2", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - // get_both_range should not panic - let result: signet_libmdbx::ReadResult>> = - cursor.get_both_range(&search_key, &search_value); - prop_assert!(result.is_ok()); - } - - /// Test cursor get_both with arbitrary key/value does not panic (V2). - #[test] - fn cursor_get_both_arbitrary_v2( - search_key in arb_small_bytes(), - search_value in arb_small_bytes(), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - txn.put(db, b"key1", b"val1", WriteFlags::empty()).unwrap(); - txn.put(db, b"key1", b"val2", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - let result: signet_libmdbx::ReadResult>> = - cursor.get_both(&search_key, &search_value); - prop_assert!(result.is_ok()); - } - - /// Test cursor get_both_range with arbitrary key/value does not panic (V2). - #[test] - fn cursor_get_both_range_arbitrary_v2( - search_key in arb_small_bytes(), - search_value in arb_small_bytes(), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - txn.put(db, b"key1", b"val1", WriteFlags::empty()).unwrap(); - txn.put(db, b"key1", b"val2", WriteFlags::empty()).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - let result: signet_libmdbx::ReadResult>> = - cursor.get_both_range(&search_key, &search_value); - prop_assert!(result.is_ok()); - } -} - -// ============================================================================= -// Iterator Operations -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test iter_from with arbitrary key does not panic (V1). - #[test] - fn iter_from_arbitrary_key_v1(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Add some data - for i in 0u8..10 { - txn.put(db, [i], [i], WriteFlags::empty()).unwrap(); - } - - let mut cursor = txn.cursor(db).unwrap(); - - // iter_from should not panic - let result = cursor.iter_from::, Vec>(&key); - prop_assert!(result.is_ok()); - - // Consuming the iterator should not panic - let count = result.unwrap().count(); - prop_assert!(count <= 10); - } - - /// Test iter_from with arbitrary key does not panic (V2). - #[test] - fn iter_from_arbitrary_key_v2(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - for i in 0u8..10 { - txn.put(db, [i], [i], WriteFlags::empty()).unwrap(); - } - - let mut cursor = txn.cursor(db).unwrap(); - - let result = cursor.iter_from::, Vec>(&key); - prop_assert!(result.is_ok()); - - let count = result.unwrap().count(); - prop_assert!(count <= 10); - } - - /// Test iter_dup_of with arbitrary key does not panic (V1). - #[test] - fn iter_dup_of_arbitrary_key_v1(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - // Add some dup data - for i in 0u8..5 { - txn.put(db, b"known_key", [i], WriteFlags::empty()).unwrap(); - } - - let mut cursor = txn.cursor(db).unwrap(); - - // iter_dup_of should not panic (yields just values, not (key, value)) - let result = cursor.iter_dup_of::>(&key); - prop_assert!(result.is_ok()); - - // Consuming the iterator should not panic - let _ = result.unwrap().count(); - } - - /// Test iter_dup_from with arbitrary key does not panic (V1). - #[test] - fn iter_dup_from_arbitrary_key_v1(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - for i in 0u8..5 { - txn.put(db, b"key_a", [i], WriteFlags::empty()).unwrap(); - txn.put(db, b"key_z", [i], WriteFlags::empty()).unwrap(); - } - - let mut cursor = txn.cursor(db).unwrap(); - - // iter_dup_from should not panic (now yields flat (key, value) pairs) - let result = cursor.iter_dup_from::, Vec>(&key); - prop_assert!(result.is_ok()); - - // Consuming iterator should not panic (no nested iteration anymore) - let _ = result.unwrap().count(); - } - - /// Test iter_dup_of with arbitrary key does not panic (V2). - #[test] - fn iter_dup_of_arbitrary_key_v2(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - for i in 0u8..5 { - txn.put(db, b"known_key", [i], WriteFlags::empty()).unwrap(); - } - - let mut cursor = txn.cursor(db).unwrap(); - - // iter_dup_of yields just values, not (key, value) - let result = cursor.iter_dup_of::>(&key); - prop_assert!(result.is_ok()); - - let _ = result.unwrap().count(); - } - - /// Test iter_dup_from with arbitrary key does not panic (V2). - #[test] - fn iter_dup_from_arbitrary_key_v2(key in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - for i in 0u8..5 { - txn.put(db, b"key_a", [i], WriteFlags::empty()).unwrap(); - txn.put(db, b"key_z", [i], WriteFlags::empty()).unwrap(); - } - - let mut cursor = txn.cursor(db).unwrap(); - - // iter_dup_from now yields flat (key, value) pairs - let result = cursor.iter_dup_from::, Vec>(&key); - prop_assert!(result.is_ok()); - - // No nested iteration anymore - just count the items - let _ = result.unwrap().count(); - } -} - -// ============================================================================= -// Cursor Put Operations -// ============================================================================= - -/// Strategy for keys that won't trigger MDBX assertion failures. -/// MDBX max key size is ~2022 bytes for 4KB pages. -fn arb_safe_key() -> impl Strategy> { - prop::collection::vec(any::(), 0..512) -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test cursor.put with arbitrary key/value does not panic (V1). - /// - /// Note: Uses constrained key sizes because MDBX aborts on very large keys - /// via cursor.put (assertion failure in cursor_put_checklen). - #[test] - fn cursor_put_arbitrary_v1(key in arb_safe_key(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - // cursor.put should not panic - let result = cursor.put(&key, &value, WriteFlags::empty()); - // Errors are fine (e.g., BadValSize), panics are not - let _ = result; - } - - /// Test cursor.put with arbitrary key/value does not panic (V2). - #[test] - fn cursor_put_arbitrary_v2(key in arb_safe_key(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let mut cursor = txn.cursor(db).unwrap(); - - let result = cursor.put(&key, &value, WriteFlags::empty()); - let _ = result; - } -} - -// ============================================================================= -// Edge Cases -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] - - /// Test empty key handling does not panic (V1). - #[test] - fn empty_key_operations_v1(value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Empty key should be valid - let put_result = txn.put(db, b"", &value, WriteFlags::empty()); - prop_assert!(put_result.is_ok()); - - let get_result: signet_libmdbx::ReadResult>> = - txn.get(db.dbi(), b""); - prop_assert!(get_result.is_ok()); - - let del_result = txn.del(db, b"", None); - prop_assert!(del_result.is_ok()); - } - - /// Test empty value handling does not panic (V1). - #[test] - fn empty_value_operations_v1(key in arb_small_bytes()) { - // Skip empty keys for this test - prop_assume!(!key.is_empty()); - - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Empty value should be valid - let put_result = txn.put(db, &key, b"", WriteFlags::empty()); - prop_assert!(put_result.is_ok()); - - let get_result: signet_libmdbx::ReadResult>> = - txn.get(db.dbi(), &key); - prop_assert!(get_result.is_ok()); - prop_assert!(get_result.unwrap().is_some()); - } - - /// Test empty key handling does not panic (V2). - #[test] - fn empty_key_operations_v2(value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put_result = txn.put(db, b"", &value, WriteFlags::empty()); - prop_assert!(put_result.is_ok()); - - let get_result: signet_libmdbx::ReadResult>> = - txn.get(db.dbi(), b""); - prop_assert!(get_result.is_ok()); - - let del_result = txn.del(db, b"", None); - prop_assert!(del_result.is_ok()); - } - - /// Test empty value handling does not panic (V2). - #[test] - fn empty_value_operations_v2(key in arb_small_bytes()) { - prop_assume!(!key.is_empty()); - - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put_result = txn.put(db, &key, b"", WriteFlags::empty()); - prop_assert!(put_result.is_ok()); - - let get_result: signet_libmdbx::ReadResult>> = - txn.get(db.dbi(), &key); - prop_assert!(get_result.is_ok()); - prop_assert!(get_result.unwrap().is_some()); - } -} - -// ============================================================================= -// Correctness: Round-trip - TxSync (V1) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that put followed by get returns the same value (V1). - #[test] - fn roundtrip_correctness_v1(key in arb_safe_key(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put_result = txn.put(db, &key, &value, WriteFlags::empty()); - if put_result.is_ok() { - let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); - prop_assert_eq!(retrieved, Some(value)); - } - } -} - -// ============================================================================= -// Correctness: Round-trip - TxUnsync (V2) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that put followed by get returns the same value (V2). - #[test] - fn roundtrip_correctness_v2(key in arb_safe_key(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put_result = txn.put(db, &key, &value, WriteFlags::empty()); - if put_result.is_ok() { - let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); - prop_assert_eq!(retrieved, Some(value)); - } - } -} - -// ============================================================================= -// Correctness: Overwrite - TxSync (V1) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that overwriting a key returns the new value (V1). - #[test] - fn overwrite_correctness_v1( - key in arb_safe_key(), - value1 in arb_bytes(), - value2 in arb_bytes(), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put1 = txn.put(db, &key, &value1, WriteFlags::empty()); - let put2 = txn.put(db, &key, &value2, WriteFlags::empty()); - - if put1.is_ok() && put2.is_ok() { - let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); - prop_assert_eq!(retrieved, Some(value2)); - } - } -} - -// ============================================================================= -// Correctness: Overwrite - TxUnsync (V2) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that overwriting a key returns the new value (V2). - #[test] - fn overwrite_correctness_v2( - key in arb_safe_key(), - value1 in arb_bytes(), - value2 in arb_bytes(), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put1 = txn.put(db, &key, &value1, WriteFlags::empty()); - let put2 = txn.put(db, &key, &value2, WriteFlags::empty()); - - if put1.is_ok() && put2.is_ok() { - let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); - prop_assert_eq!(retrieved, Some(value2)); - } - } -} - -// ============================================================================= -// Correctness: Delete - TxSync (V1) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that delete removes the key and get returns None (V1). - #[test] - fn delete_correctness_v1(key in arb_safe_key(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put_result = txn.put(db, &key, &value, WriteFlags::empty()); - if put_result.is_ok() { - let deleted = txn.del(db, &key, None).unwrap(); - prop_assert!(deleted); - - let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); - prop_assert_eq!(retrieved, None); - } - } -} - -// ============================================================================= -// Correctness: Delete - TxUnsync (V2) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that delete removes the key and get returns None (V2). - #[test] - fn delete_correctness_v2(key in arb_safe_key(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put_result = txn.put(db, &key, &value, WriteFlags::empty()); - if put_result.is_ok() { - let deleted = txn.del(db, &key, None).unwrap(); - prop_assert!(deleted); - - let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); - prop_assert_eq!(retrieved, None); - } - } -} - -// ============================================================================= -// Correctness: DUP_SORT Values - TxSync (V1) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test that all unique DUP_SORT values are retrievable via iter_dup_of (V1). - #[test] - fn dupsort_values_correctness_v1( - key in arb_small_bytes(), - values in prop::collection::vec(arb_small_bytes(), 1..10), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - // Insert all values - let mut inserted: Vec> = Vec::new(); - for value in &values { - if txn.put(db, &key, value, WriteFlags::empty()).is_ok() - && !inserted.contains(value) - { - inserted.push(value.clone()); - } - } - - // Skip if nothing was inserted - prop_assume!(!inserted.is_empty()); - - // Retrieve all values via iter_dup_of (yields just values, not (key, value)) - let mut cursor = txn.cursor(db).unwrap(); - let retrieved: Vec> = - cursor.iter_dup_of::>(&key).unwrap().filter_map(Result::ok).collect(); - - // All inserted values should be retrieved (order is sorted by MDBX) - inserted.sort(); - let mut retrieved_sorted = retrieved.clone(); - retrieved_sorted.sort(); - prop_assert_eq!(inserted, retrieved_sorted); - } -} - -// ============================================================================= -// Correctness: DUP_SORT Values - TxUnsync (V2) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test that all unique DUP_SORT values are retrievable via iter_dup_of (V2). - #[test] - fn dupsort_values_correctness_v2( - key in arb_small_bytes(), - values in prop::collection::vec(arb_small_bytes(), 1..10), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); - - let mut inserted: Vec> = Vec::new(); - for value in &values { - if txn.put(db, &key, value, WriteFlags::empty()).is_ok() - && !inserted.contains(value) - { - inserted.push(value.clone()); - } - } - - prop_assume!(!inserted.is_empty()); - - // iter_dup_of yields just values, not (key, value) - let mut cursor = txn.cursor(db).unwrap(); - let retrieved: Vec> = - cursor.iter_dup_of::>(&key).unwrap().filter_map(Result::ok).collect(); - - inserted.sort(); - let mut retrieved_sorted = retrieved.clone(); - retrieved_sorted.sort(); - prop_assert_eq!(inserted, retrieved_sorted); - } -} - -// ============================================================================= -// Correctness: Iteration Order - TxSync (V1) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test that keys are returned in lexicographically sorted order (V1). - #[test] - fn iteration_order_correctness_v1( - entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 1..20), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - // Insert all entries - let mut inserted_keys: Vec> = Vec::new(); - for (key, value) in &entries { - if txn.put(db, key, value, WriteFlags::empty()).is_ok() - && !inserted_keys.contains(key) - { - inserted_keys.push(key.clone()); - } - } - - prop_assume!(!inserted_keys.is_empty()); - - // Iterate and collect keys - let mut cursor = txn.cursor(db).unwrap(); - let retrieved_keys: Vec> = cursor - .iter::, Vec>() - .filter_map(Result::ok) - .map(|(k, _)| k) - .collect(); - - // Keys should be in sorted order - let mut expected = inserted_keys; - expected.sort(); - expected.dedup(); - prop_assert_eq!(retrieved_keys, expected); - } -} - -// ============================================================================= -// Correctness: Iteration Order - TxUnsync (V2) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test that keys are returned in lexicographically sorted order (V2). - #[test] - fn iteration_order_correctness_v2( - entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 1..20), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let mut inserted_keys: Vec> = Vec::new(); - for (key, value) in &entries { - if txn.put(db, key, value, WriteFlags::empty()).is_ok() - && !inserted_keys.contains(key) - { - inserted_keys.push(key.clone()); - } - } - - prop_assume!(!inserted_keys.is_empty()); - - let mut cursor = txn.cursor(db).unwrap(); - let retrieved_keys: Vec> = cursor - .iter::, Vec>() - .filter_map(Result::ok) - .map(|(k, _)| k) - .collect(); - - let mut expected = inserted_keys; - expected.sort(); - expected.dedup(); - prop_assert_eq!(retrieved_keys, expected); - } -} - -// ============================================================================= -// Correctness: Cursor Set - TxSync (V1) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that cursor.set returns the correct value when key exists (V1). - #[test] - fn cursor_set_correctness_v1(key in arb_safe_key(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put_result = txn.put(db, &key, &value, WriteFlags::empty()); - if put_result.is_ok() { - let mut cursor = txn.cursor(db).unwrap(); - let retrieved: Option> = cursor.set(&key).unwrap(); - prop_assert_eq!(retrieved, Some(value)); - } - } -} - -// ============================================================================= -// Correctness: Cursor Set - TxUnsync (V2) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Test that cursor.set returns the correct value when key exists (V2). - #[test] - fn cursor_set_correctness_v2(key in arb_safe_key(), value in arb_bytes()) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let put_result = txn.put(db, &key, &value, WriteFlags::empty()); - if put_result.is_ok() { - let mut cursor = txn.cursor(db).unwrap(); - let retrieved: Option> = cursor.set(&key).unwrap(); - prop_assert_eq!(retrieved, Some(value)); - } - } -} - -// ============================================================================= -// Correctness: Cursor Set Range - TxSync (V1) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test that cursor.set_range returns the first key >= search key (V1). - #[test] - fn cursor_set_range_correctness_v1( - entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 2..10), - search_key in arb_safe_key(), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_sync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let mut inserted: Vec<(Vec, Vec)> = Vec::new(); - for (key, value) in &entries { - if txn.put(db, key, value, WriteFlags::empty()).is_ok() { - inserted.push((key.clone(), value.clone())); - } - } - - prop_assume!(!inserted.is_empty()); - - // Sort by key to find expected result - inserted.sort_by(|a, b| a.0.cmp(&b.0)); - inserted.dedup_by(|a, b| a.0 == b.0); - - let expected = inserted - .iter() - .find(|(k, _)| k >= &search_key) - .cloned(); - - let mut cursor = txn.cursor(db).unwrap(); - let result: Option<(Vec, Vec)> = cursor.set_range(&search_key).unwrap(); - - prop_assert_eq!(result, expected); - } -} - -// ============================================================================= -// Correctness: Cursor Set Range - TxUnsync (V2) -// ============================================================================= - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// Test that cursor.set_range returns the first key >= search key (V2). - #[test] - fn cursor_set_range_correctness_v2( - entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 2..10), - search_key in arb_safe_key(), - ) { - let dir = tempdir().unwrap(); - let env = Environment::builder().open(dir.path()).unwrap(); - let txn = env.begin_rw_unsync().unwrap(); - let db = txn.open_db(None).unwrap(); - - let mut inserted: Vec<(Vec, Vec)> = Vec::new(); - for (key, value) in &entries { - if txn.put(db, key, value, WriteFlags::empty()).is_ok() { - inserted.push((key.clone(), value.clone())); - } - } - - prop_assume!(!inserted.is_empty()); - - inserted.sort_by(|a, b| a.0.cmp(&b.0)); - inserted.dedup_by(|a, b| a.0 == b.0); - - let expected = inserted - .iter() - .find(|(k, _)| k >= &search_key) - .cloned(); - - let mut cursor = txn.cursor(db).unwrap(); - let result: Option<(Vec, Vec)> = cursor.set_range(&search_key).unwrap(); - - prop_assert_eq!(result, expected); - } -} diff --git a/tests/proptest_iter.rs b/tests/proptest_iter.rs new file mode 100644 index 0000000..358f05d --- /dev/null +++ b/tests/proptest_iter.rs @@ -0,0 +1,308 @@ +//! Property-based tests for iterator operations. +//! +//! Tests focus on both "does not panic" and correctness properties. Errors are +//! acceptable (e.g., `BadValSize`), panics are not. +#![allow(missing_docs)] + +use proptest::prelude::*; +use signet_libmdbx::{DatabaseFlags, Environment, WriteFlags}; +use tempfile::tempdir; + +/// Strategy for generating byte vectors of various sizes (0 to 1KB). +fn arb_bytes() -> impl Strategy> { + prop::collection::vec(any::(), 0..1024) +} + +/// Strategy for keys that won't trigger MDBX assertion failures. +/// MDBX max key size is ~2022 bytes for 4KB pages. +fn arb_safe_key() -> impl Strategy> { + prop::collection::vec(any::(), 1..512) +} + +// ============================================================================= +// Migrated: iter_from — TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test iter_from with arbitrary key does not panic (V1). + #[test] + fn iter_from_arbitrary_key_v1(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + for i in 0u8..10 { + txn.put(db, [i], [i], WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + + let result = cursor.iter_from::, Vec>(&key); + prop_assert!(result.is_ok()); + + let count = result.unwrap().count(); + prop_assert!(count <= 10); + } + + /// Test iter_dup_of with arbitrary key does not panic (V1). + #[test] + fn iter_dup_of_arbitrary_key_v1(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for i in 0u8..5 { + txn.put(db, b"known_key", [i], WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + + let result = cursor.iter_dup_of::>(&key); + prop_assert!(result.is_ok()); + + let _ = result.unwrap().count(); + } + + /// Test iter_dup_from with arbitrary key does not panic (V1). + #[test] + fn iter_dup_from_arbitrary_key_v1(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for i in 0u8..5 { + txn.put(db, b"key_a", [i], WriteFlags::empty()).unwrap(); + txn.put(db, b"key_z", [i], WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + + let result = cursor.iter_dup_from::, Vec>(&key); + prop_assert!(result.is_ok()); + + let _ = result.unwrap().count(); + } +} + +// ============================================================================= +// Migrated: iter_from — TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test iter_from with arbitrary key does not panic (V2). + #[test] + fn iter_from_arbitrary_key_v2(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + for i in 0u8..10 { + txn.put(db, [i], [i], WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + + let result = cursor.iter_from::, Vec>(&key); + prop_assert!(result.is_ok()); + + let count = result.unwrap().count(); + prop_assert!(count <= 10); + } + + /// Test iter_dup_of with arbitrary key does not panic (V2). + #[test] + fn iter_dup_of_arbitrary_key_v2(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for i in 0u8..5 { + txn.put(db, b"known_key", [i], WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + + let result = cursor.iter_dup_of::>(&key); + prop_assert!(result.is_ok()); + + let _ = result.unwrap().count(); + } + + /// Test iter_dup_from with arbitrary key does not panic (V2). + #[test] + fn iter_dup_from_arbitrary_key_v2(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.create_db(None, DatabaseFlags::DUP_SORT).unwrap(); + + for i in 0u8..5 { + txn.put(db, b"key_a", [i], WriteFlags::empty()).unwrap(); + txn.put(db, b"key_z", [i], WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + + let result = cursor.iter_dup_from::, Vec>(&key); + prop_assert!(result.is_ok()); + + let _ = result.unwrap().count(); + } +} + +// ============================================================================= +// New: iter_start yields all — TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that iter_start yields exactly the number of distinct keys inserted (V1). + #[test] + fn iter_start_yields_all_v1( + keys in prop::collection::vec(arb_safe_key(), 1..20), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Deduplicate and sort keys, then insert all. + let mut unique_keys = keys; + unique_keys.sort(); + unique_keys.dedup(); + + for key in &unique_keys { + txn.put(db, key, b"v", WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + let count = cursor + .iter_start::, Vec>() + .unwrap() + .filter_map(Result::ok) + .count(); + + prop_assert_eq!(count, unique_keys.len()); + } +} + +// ============================================================================= +// New: iter_start yields all — TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that iter_start yields exactly the number of distinct keys inserted (V2). + #[test] + fn iter_start_yields_all_v2( + keys in prop::collection::vec(arb_safe_key(), 1..20), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let mut unique_keys = keys; + unique_keys.sort(); + unique_keys.dedup(); + + for key in &unique_keys { + txn.put(db, key, b"v", WriteFlags::empty()).unwrap(); + } + + let mut cursor = txn.cursor(db).unwrap(); + let count = cursor + .iter_start::, Vec>() + .unwrap() + .filter_map(Result::ok) + .count(); + + prop_assert_eq!(count, unique_keys.len()); + } +} + +// ============================================================================= +// New: iter_from bounds — TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that iter_from returns only keys >= search key (V1). + #[test] + fn iter_from_bounds_v1(search_idx in 0usize..20) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Insert 20 single-byte keys [0]...[19]. + for i in 0u8..20 { + txn.put(db, [i], b"v", WriteFlags::empty()).unwrap(); + } + + let search_key = [search_idx as u8]; + let mut cursor = txn.cursor(db).unwrap(); + let retrieved_keys: Vec> = cursor + .iter_from::, Vec>(&search_key) + .unwrap() + .filter_map(Result::ok) + .map(|(k, _)| k) + .collect(); + + // All returned keys must be >= search_key. + for k in &retrieved_keys { + prop_assert!(k.as_slice() >= search_key.as_slice()); + } + + // The number of returned keys should be 20 - search_idx. + prop_assert_eq!(retrieved_keys.len(), 20 - search_idx); + } +} + +// ============================================================================= +// New: iter_from bounds — TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that iter_from returns only keys >= search key (V2). + #[test] + fn iter_from_bounds_v2(search_idx in 0usize..20) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + for i in 0u8..20 { + txn.put(db, [i], b"v", WriteFlags::empty()).unwrap(); + } + + let search_key = [search_idx as u8]; + let mut cursor = txn.cursor(db).unwrap(); + let retrieved_keys: Vec> = cursor + .iter_from::, Vec>(&search_key) + .unwrap() + .filter_map(Result::ok) + .map(|(k, _)| k) + .collect(); + + for k in &retrieved_keys { + prop_assert!(k.as_slice() >= search_key.as_slice()); + } + + prop_assert_eq!(retrieved_keys.len(), 20 - search_idx); + } +} diff --git a/tests/proptest_kv.rs b/tests/proptest_kv.rs new file mode 100644 index 0000000..a675dfc --- /dev/null +++ b/tests/proptest_kv.rs @@ -0,0 +1,571 @@ +//! Property-based tests for key/value operations. +//! +//! Tests focus on both "does not panic" and correctness properties. Errors are +//! acceptable (e.g., `BadValSize`), panics are not. +#![allow(missing_docs)] + +use proptest::prelude::*; +use signet_libmdbx::{Environment, Geometry, WriteFlags}; +use tempfile::tempdir; + +/// Strategy for generating byte vectors of various sizes (0 to 1KB). +fn arb_bytes() -> impl Strategy> { + prop::collection::vec(any::(), 0..1024) +} + +/// Strategy for generating small byte vectors (0 to 64 bytes). +fn arb_small_bytes() -> impl Strategy> { + prop::collection::vec(any::(), 0..64) +} + +/// Strategy for keys that won't trigger MDBX assertion failures. +/// MDBX max key size is ~2022 bytes for 4KB pages. +fn arb_safe_key() -> impl Strategy> { + prop::collection::vec(any::(), 0..512) +} + +// ============================================================================= +// Key/Value Operations - TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that put/get with arbitrary key/value does not panic (V1). + #[test] + fn put_get_arbitrary_kv_v1(key in arb_bytes(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Should not panic - may return error for invalid sizes + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + + // If put succeeded, get should not panic + if put_result.is_ok() { + let _: Option> = txn.get(db.dbi(), &key).unwrap(); + } + } + + /// Test that del with nonexistent arbitrary key does not panic (V1). + #[test] + fn del_nonexistent_key_v1(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Delete on nonexistent key should return Ok(false), not panic + let result = txn.del(db, &key, None); + prop_assert!(result.is_ok()); + prop_assert!(!result.unwrap()); + } + + /// Test that get with arbitrary key on empty db does not panic (V1). + #[test] + fn get_arbitrary_key_empty_db_v1(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_ro_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Get on nonexistent key should return Ok(None), not panic + let result: signet_libmdbx::ReadResult>> = txn.get(db.dbi(), &key); + prop_assert!(result.is_ok()); + prop_assert!(result.unwrap().is_none()); + } + + /// Test empty key handling does not panic (V1). + #[test] + fn empty_key_operations_v1(value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Empty key should be valid + let put_result = txn.put(db, b"", &value, WriteFlags::empty()); + prop_assert!(put_result.is_ok()); + + let get_result: signet_libmdbx::ReadResult>> = + txn.get(db.dbi(), b""); + prop_assert!(get_result.is_ok()); + + let del_result = txn.del(db, b"", None); + prop_assert!(del_result.is_ok()); + } + + /// Test empty value handling does not panic (V1). + #[test] + fn empty_value_operations_v1(key in arb_small_bytes()) { + // Skip empty keys for this test + prop_assume!(!key.is_empty()); + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Empty value should be valid + let put_result = txn.put(db, &key, b"", WriteFlags::empty()); + prop_assert!(put_result.is_ok()); + + let get_result: signet_libmdbx::ReadResult>> = + txn.get(db.dbi(), &key); + prop_assert!(get_result.is_ok()); + prop_assert!(get_result.unwrap().is_some()); + } +} + +// ============================================================================= +// Key/Value Operations - TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that put/get with arbitrary key/value does not panic (V2). + #[test] + fn put_get_arbitrary_kv_v2(key in arb_bytes(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Should not panic - may return error for invalid sizes + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + + // If put succeeded, get should not panic + if put_result.is_ok() { + let _: Option> = txn.get(db.dbi(), &key).unwrap(); + } + } + + /// Test that del with nonexistent arbitrary key does not panic (V2). + #[test] + fn del_nonexistent_key_v2(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Delete on nonexistent key should return Ok(false), not panic + let result = txn.del(db, &key, None); + prop_assert!(result.is_ok()); + prop_assert!(!result.unwrap()); + } + + /// Test that get with arbitrary key on empty db does not panic (V2). + #[test] + fn get_arbitrary_key_empty_db_v2(key in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_ro_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Get on nonexistent key should return Ok(None), not panic + let result: signet_libmdbx::ReadResult>> = txn.get(db.dbi(), &key); + prop_assert!(result.is_ok()); + prop_assert!(result.unwrap().is_none()); + } + + /// Test empty key handling does not panic (V2). + #[test] + fn empty_key_operations_v2(value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, b"", &value, WriteFlags::empty()); + prop_assert!(put_result.is_ok()); + + let get_result: signet_libmdbx::ReadResult>> = + txn.get(db.dbi(), b""); + prop_assert!(get_result.is_ok()); + + let del_result = txn.del(db, b"", None); + prop_assert!(del_result.is_ok()); + } + + /// Test empty value handling does not panic (V2). + #[test] + fn empty_value_operations_v2(key in arb_small_bytes()) { + prop_assume!(!key.is_empty()); + + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, b"", WriteFlags::empty()); + prop_assert!(put_result.is_ok()); + + let get_result: signet_libmdbx::ReadResult>> = + txn.get(db.dbi(), &key); + prop_assert!(get_result.is_ok()); + prop_assert!(get_result.unwrap().is_some()); + } +} + +// ============================================================================= +// Correctness: Round-trip - TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that put followed by get returns the same value (V1). + #[test] + fn roundtrip_correctness_v1(key in arb_safe_key(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, Some(value)); + } + } +} + +// ============================================================================= +// Correctness: Round-trip - TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that put followed by get returns the same value (V2). + #[test] + fn roundtrip_correctness_v2(key in arb_safe_key(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, Some(value)); + } + } +} + +// ============================================================================= +// Correctness: Overwrite - TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that overwriting a key returns the new value (V1). + #[test] + fn overwrite_correctness_v1( + key in arb_safe_key(), + value1 in arb_bytes(), + value2 in arb_bytes(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put1 = txn.put(db, &key, &value1, WriteFlags::empty()); + let put2 = txn.put(db, &key, &value2, WriteFlags::empty()); + + if put1.is_ok() && put2.is_ok() { + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, Some(value2)); + } + } +} + +// ============================================================================= +// Correctness: Overwrite - TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that overwriting a key returns the new value (V2). + #[test] + fn overwrite_correctness_v2( + key in arb_safe_key(), + value1 in arb_bytes(), + value2 in arb_bytes(), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put1 = txn.put(db, &key, &value1, WriteFlags::empty()); + let put2 = txn.put(db, &key, &value2, WriteFlags::empty()); + + if put1.is_ok() && put2.is_ok() { + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, Some(value2)); + } + } +} + +// ============================================================================= +// Correctness: Delete - TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that delete removes the key and get returns None (V1). + #[test] + fn delete_correctness_v1(key in arb_safe_key(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + let deleted = txn.del(db, &key, None).unwrap(); + prop_assert!(deleted); + + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, None); + } + } +} + +// ============================================================================= +// Correctness: Delete - TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Test that delete removes the key and get returns None (V2). + #[test] + fn delete_correctness_v2(key in arb_safe_key(), value in arb_bytes()) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + let deleted = txn.del(db, &key, None).unwrap(); + prop_assert!(deleted); + + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, None); + } + } +} + +// ============================================================================= +// Correctness: Iteration Order - TxSync (V1) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that keys are returned in lexicographically sorted order (V1). + #[test] + fn iteration_order_correctness_v1( + entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 1..20), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Insert all entries + let mut inserted_keys: Vec> = Vec::new(); + for (key, value) in &entries { + if txn.put(db, key, value, WriteFlags::empty()).is_ok() + && !inserted_keys.contains(key) + { + inserted_keys.push(key.clone()); + } + } + + prop_assume!(!inserted_keys.is_empty()); + + // Iterate and collect keys + let mut cursor = txn.cursor(db).unwrap(); + let retrieved_keys: Vec> = cursor + .iter::, Vec>() + .filter_map(Result::ok) + .map(|(k, _)| k) + .collect(); + + // Keys should be in sorted order + let mut expected = inserted_keys; + expected.sort(); + expected.dedup(); + prop_assert_eq!(retrieved_keys, expected); + } +} + +// ============================================================================= +// Correctness: Iteration Order - TxUnsync (V2) +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that keys are returned in lexicographically sorted order (V2). + #[test] + fn iteration_order_correctness_v2( + entries in prop::collection::vec((arb_safe_key(), arb_bytes()), 1..20), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let mut inserted_keys: Vec> = Vec::new(); + for (key, value) in &entries { + if txn.put(db, key, value, WriteFlags::empty()).is_ok() + && !inserted_keys.contains(key) + { + inserted_keys.push(key.clone()); + } + } + + prop_assume!(!inserted_keys.is_empty()); + + let mut cursor = txn.cursor(db).unwrap(); + let retrieved_keys: Vec> = cursor + .iter::, Vec>() + .filter_map(Result::ok) + .map(|(k, _)| k) + .collect(); + + let mut expected = inserted_keys; + expected.sort(); + expected.dedup(); + prop_assert_eq!(retrieved_keys, expected); + } +} + +// ============================================================================= +// New: Large Value Roundtrip +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + + /// Test roundtrip with large values (up to 64KB) using a larger environment (V1). + #[test] + fn large_value_roundtrip_v1( + key in arb_safe_key(), + value in prop::collection::vec(any::(), 0..65536), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_geometry(Geometry { size: Some(0..(256 * 1024 * 1024)), ..Default::default() }) + .open(dir.path()) + .unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, Some(value)); + } + } + + /// Test roundtrip with large values (up to 64KB) using a larger environment (V2). + #[test] + fn large_value_roundtrip_v2( + key in arb_safe_key(), + value in prop::collection::vec(any::(), 0..65536), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_geometry(Geometry { size: Some(0..(256 * 1024 * 1024)), ..Default::default() }) + .open(dir.path()) + .unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db = txn.open_db(None).unwrap(); + + let put_result = txn.put(db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, Some(value)); + } + } +} + +// ============================================================================= +// New: Multi-Database Isolation +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// Test that two named databases are isolated from each other (V1). + #[test] + fn multi_database_isolation_v1( + key in arb_safe_key(), + value_a in arb_bytes(), + value_b in arb_bytes(), + ) { + // Values must differ for isolation check to be meaningful + prop_assume!(value_a != value_b); + + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_max_dbs(4) + .open(dir.path()) + .unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db_a = txn.create_db(Some("db_a"), signet_libmdbx::DatabaseFlags::empty()).unwrap(); + let db_b = txn.create_db(Some("db_b"), signet_libmdbx::DatabaseFlags::empty()).unwrap(); + + let put_a = txn.put(db_a, &key, &value_a, WriteFlags::empty()); + let put_b = txn.put(db_b, &key, &value_b, WriteFlags::empty()); + + if put_a.is_ok() && put_b.is_ok() { + let retrieved_a: Option> = txn.get(db_a.dbi(), &key).unwrap(); + let retrieved_b: Option> = txn.get(db_b.dbi(), &key).unwrap(); + // Each db should return its own value, not the other's + prop_assert_eq!(retrieved_a, Some(value_a)); + prop_assert_eq!(retrieved_b, Some(value_b)); + } + } + + /// Test that two named databases are isolated from each other (V2). + #[test] + fn multi_database_isolation_v2( + key in arb_safe_key(), + value_a in arb_bytes(), + value_b in arb_bytes(), + ) { + prop_assume!(value_a != value_b); + + let dir = tempdir().unwrap(); + let env = Environment::builder() + .set_max_dbs(4) + .open(dir.path()) + .unwrap(); + let txn = env.begin_rw_unsync().unwrap(); + let db_a = txn.create_db(Some("db_a"), signet_libmdbx::DatabaseFlags::empty()).unwrap(); + let db_b = txn.create_db(Some("db_b"), signet_libmdbx::DatabaseFlags::empty()).unwrap(); + + let put_a = txn.put(db_a, &key, &value_a, WriteFlags::empty()); + let put_b = txn.put(db_b, &key, &value_b, WriteFlags::empty()); + + if put_a.is_ok() && put_b.is_ok() { + let retrieved_a: Option> = txn.get(db_a.dbi(), &key).unwrap(); + let retrieved_b: Option> = txn.get(db_b.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved_a, Some(value_a)); + prop_assert_eq!(retrieved_b, Some(value_b)); + } + } +} diff --git a/tests/proptest_nested.rs b/tests/proptest_nested.rs new file mode 100644 index 0000000..423eed7 --- /dev/null +++ b/tests/proptest_nested.rs @@ -0,0 +1,132 @@ +//! Property-based tests for nested transaction behavior (TxSync / V1 only). +//! +//! Tests focus on correctness of commit/abort semantics. Errors are +//! acceptable (e.g., `BadValSize`), panics are not. +#![allow(missing_docs)] + +use proptest::prelude::*; +use signet_libmdbx::{Environment, WriteFlags}; +use tempfile::tempdir; + +/// Strategy for keys that won't trigger MDBX assertion failures (non-empty). +fn arb_safe_key() -> impl Strategy> { + prop::collection::vec(any::(), 1..512) +} + +// ============================================================================= +// Nested commit preserves writes +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Test that a write in a committed child transaction is visible in the parent (V1). + #[test] + fn nested_commit_preserves_writes( + key in arb_safe_key(), + value in prop::collection::vec(any::(), 1..64), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + { + let nested = txn.begin_nested_txn().unwrap(); + let nested_db = nested.open_db(None).unwrap(); + let put_result = nested.put(nested_db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + nested.commit().unwrap(); + + // After commit, parent should see the value. + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert_eq!(retrieved, Some(value)); + } + } + } +} + +// ============================================================================= +// Nested abort discards writes +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Test that a write in a dropped (aborted) child transaction is NOT visible in the parent (V1). + #[test] + fn nested_abort_discards_writes( + key in arb_safe_key(), + value in prop::collection::vec(any::(), 1..64), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Confirm the key is not yet present. + let before: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assume!(before.is_none()); + + { + let nested = txn.begin_nested_txn().unwrap(); + let nested_db = nested.open_db(None).unwrap(); + let put_result = nested.put(nested_db, &key, &value, WriteFlags::empty()); + if put_result.is_ok() { + // Drop without committing — this aborts the nested transaction. + drop(nested); + + // Parent should NOT see the value. + let retrieved: Option> = txn.get(db.dbi(), &key).unwrap(); + prop_assert!(retrieved.is_none()); + } + } + } +} + +// ============================================================================= +// Parent writes survive child abort +// ============================================================================= + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Test that parent writes survive a child abort, and that the child's write is discarded (V1). + #[test] + fn parent_writes_survive_child_abort( + parent_key in arb_safe_key(), + parent_value in prop::collection::vec(any::(), 1..64), + child_key in arb_safe_key(), + child_value in prop::collection::vec(any::(), 1..64), + ) { + let dir = tempdir().unwrap(); + let env = Environment::builder().open(dir.path()).unwrap(); + let txn = env.begin_rw_sync().unwrap(); + let db = txn.open_db(None).unwrap(); + + // Write in parent. + let put_result = txn.put(db, &parent_key, &parent_value, WriteFlags::empty()); + if put_result.is_err() { + return Ok(()); + } + + { + let nested = txn.begin_nested_txn().unwrap(); + let nested_db = nested.open_db(None).unwrap(); + // Write something in the child (ignore errors). + let _ = nested.put(nested_db, &child_key, &child_value, WriteFlags::empty()); + // Abort by dropping. + drop(nested); + } + + // Parent write must still be visible. + let retrieved: Option> = txn.get(db.dbi(), &parent_key).unwrap(); + prop_assert_eq!(retrieved, Some(parent_value)); + + // If the keys differ, child write must NOT be visible. + if parent_key != child_key { + let child_retrieved: Option> = txn.get(db.dbi(), &child_key).unwrap(); + prop_assert!(child_retrieved.is_none()); + } + } +}