#[cfg(all(feature = "native-sqlite", feature = "_has-encryption"))]
compile_error!(
"Features `native-sqlite` and `encryption`/`encryption-cc` are mutually exclusive.\n\
If you ran `cargo install`, use:\n \
cargo install dynoxide-rs --no-default-features --features encrypted-server\n\
If using as a library dependency, set `default-features = false` \
and enable only one backend."
);
#[cfg(all(feature = "encryption", feature = "encryption-cc"))]
compile_error!(
"Features `encryption` and `encryption-cc` are mutually exclusive. \
Use `encryption` for vendored OpenSSL or `encryption-cc` for Apple CommonCrypto."
);
#[cfg(all(feature = "encryption-cc", not(target_vendor = "apple")))]
compile_error!(
"The `encryption-cc` feature is intended for Apple platforms only (CommonCrypto). \
Use the `encryption` feature for vendored OpenSSL on non-Apple platforms."
);
#[cfg(all(
feature = "wasm-sqlite",
any(
feature = "native-sqlite",
feature = "_has-encryption",
feature = "cli"
)
))]
compile_error!(
"The `wasm-sqlite` backend is mutually exclusive with the native backends and \
with the CLI, server and MCP features, which are native-only.\n\
If your manifest names none of those, they came from the defaults: Cargo adds \
the default features to whatever you list, so `features = [\"wasm-sqlite\"]` \
enables the native backend as well. Turn them off:\n \
dynoxide-rs = { version = \"...\", default-features = false, features = [\"wasm-sqlite\"] }"
);
#[cfg(not(any(
feature = "native-sqlite",
feature = "_has-encryption",
feature = "wasm-sqlite"
)))]
compile_error!(
"A storage backend feature must be enabled: `native-sqlite`, `encryption`, \
`encryption-cc`, or `wasm-sqlite`. Default features include `native-sqlite`. \
If you used `default-features = false`, add one of these features."
);
pub mod actions;
pub mod auth_material;
pub mod bench_counters;
pub(crate) mod empty_names;
pub mod errors;
pub(crate) mod expressions;
#[cfg(feature = "import")]
pub mod import;
#[doc(hidden)]
pub mod macros;
#[cfg(feature = "mcp-server")]
pub mod mcp;
#[cfg(any(feature = "http-server", feature = "mcp-server"))]
pub(crate) mod net;
pub(crate) mod partiql;
pub mod schema;
pub(crate) mod set_order;
pub(crate) mod serde_errors;
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
pub(crate) mod serialization_checks;
pub(crate) mod value_nesting;
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
pub(crate) mod deep_nesting;
#[cfg(feature = "http-server")]
pub mod server;
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub(crate) mod handoff_mutex;
#[cfg(any(feature = "http-server", feature = "mcp-server"))]
mod server_options;
#[cfg(feature = "mcp-server")]
pub(crate) mod snapshots;
pub mod storage;
pub mod storage_backend;
pub mod streams;
pub mod ttl;
pub mod types;
pub mod validation;
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
pub(crate) mod dynamo_ops;
#[cfg(any(feature = "wasm-sqlite", test))]
pub mod wasm_api;
#[cfg(feature = "wasm-harness")]
pub mod wasm_harness;
#[doc(hidden)]
pub use macros::ItemInsert;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use web_time::{Duration, Instant};
pub use errors::{DynoxideError, Result};
pub use storage::{DatabaseInfo, TableInfoEntry, TableMetadata, TableStats};
pub use storage_backend::BackendError;
#[cfg(feature = "wasm-sqlite")]
pub use storage_backend::WasmBridgeBackend;
pub use types::{AttributeValue, ConversionError, Item};
#[cfg(any(feature = "http-server", feature = "mcp-server"))]
pub use server_options::ServerOptions;
pub const PRODUCT_VERSION: &str = env!("DYNOXIDE_PRODUCT_VERSION");
use actions::vector_lifecycle::VectorIndexLifecycle;
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
pub record_streams: bool,
pub set_cached_at: bool,
}
#[derive(Debug, Clone)]
pub struct ImportResult {
pub items_imported: usize,
pub bytes_imported: usize,
}
type TokenSlot<T> = (Instant, u64, Option<T>);
type TokenCache<T> = HashMap<String, TokenSlot<T>>;
type TransactWriteTokenCache = TokenCache<actions::transact_write_items::CachedWrite>;
type ExecuteTransactionTokenCache = TokenCache<actions::execute_transaction::CachedTransaction>;
#[derive(Default)]
pub struct TokenCaches {
#[cfg_attr(
not(any(feature = "native-sqlite", feature = "_has-encryption")),
allow(dead_code)
)]
transact_write: Mutex<TransactWriteTokenCache>,
execute_transaction: Mutex<ExecuteTransactionTokenCache>,
}
impl TokenCaches {
pub fn new() -> Self {
Self::default()
}
#[cfg(any(feature = "wasm-sqlite", test))]
pub(crate) fn execute_transaction(&self) -> &Mutex<ExecuteTransactionTokenCache> {
&self.execute_transaction
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub(crate) fn lock_all(&self) -> TokenCacheGuards<'_> {
TokenCacheGuards {
transact_write: self
.transact_write
.lock()
.unwrap_or_else(|e| e.into_inner()),
execute_transaction: self
.execute_transaction
.lock()
.unwrap_or_else(|e| e.into_inner()),
}
}
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub(crate) struct TokenCacheGuards<'a> {
transact_write: std::sync::MutexGuard<'a, TransactWriteTokenCache>,
execute_transaction: std::sync::MutexGuard<'a, ExecuteTransactionTokenCache>,
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
impl TokenCacheGuards<'_> {
pub(crate) fn clear(&mut self) {
self.transact_write.clear();
self.execute_transaction.clear();
}
}
const MAX_TOKEN_LEN: usize = 36;
const TOKEN_EXPIRY_SECS: u64 = 600;
fn validate_token(token: Option<&str>) -> Result<()> {
match token_constraint_error(token) {
Some(msg) => Err(DynoxideError::ValidationException(
validation::envelope_message(&msg),
)),
None => Ok(()),
}
}
pub(crate) fn token_constraint_error(token: Option<&str>) -> Option<String> {
match token {
Some(token) if token.len() > MAX_TOKEN_LEN => Some(format!(
"Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
)),
_ => None,
}
}
fn request_hash<H: serde::Serialize>(input: &H) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
canonical_json(input).hash(&mut hasher);
hasher.finish()
}
pub(crate) fn canonical_json<T: serde::Serialize + ?Sized>(value: &T) -> Vec<u8> {
serde_json::to_value(value)
.map(sorted_json)
.and_then(|v| serde_json::to_vec(&v))
.unwrap_or_default()
}
pub(crate) fn sorted_json(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
serde_json::Value::Object(
entries
.into_iter()
.map(|(k, v)| (k, sorted_json(v)))
.collect(),
)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.into_iter().map(sorted_json).collect())
}
other => other,
}
}
fn lock_cache<T>(cache: &Mutex<TokenCache<T>>) -> Result<std::sync::MutexGuard<'_, TokenCache<T>>> {
cache
.lock()
.map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))
}
fn evict_expired<T>(cache: &mut TokenCache<T>, window: Duration) {
cache.retain(|_, (claimed_at, _, _)| claimed_at.elapsed() < window);
}
fn token_window() -> Duration {
Duration::from_secs(TOKEN_EXPIRY_SECS)
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
fn run_idempotent<T, H, E, R>(
cache: &Mutex<TokenCache<T>>,
token: Option<&str>,
hash_input: &H,
execute: E,
replay: R,
) -> Result<T>
where
T: Clone,
H: serde::Serialize,
E: FnOnce() -> Result<T>,
R: FnOnce(&T) -> T,
{
validate_token(token)?;
let Some(token) = token else {
return execute();
};
let hash = request_hash(hash_input);
let mut cache = lock_cache(cache)?;
evict_expired(&mut cache, token_window());
let cached = match cache.get(token) {
Some((_, cached_hash, _)) if *cached_hash != hash => {
return Err(DynoxideError::IdempotentParameterMismatchException(
"An error occurred (IdempotentParameterMismatchException)".to_string(),
));
}
Some((_, _, slot)) => slot.clone(),
None => None,
};
if let Some(cached) = cached {
drop(cache);
return Ok(replay(&cached));
}
let resp = execute()?;
cache.insert(
token.to_string(),
(Instant::now(), hash, Some(resp.clone())),
);
Ok(resp)
}
#[cfg(any(feature = "wasm-sqlite", test))]
const IN_FLIGHT_MESSAGE: &str = "a call under this ClientRequestToken is still in flight";
#[cfg(any(feature = "wasm-sqlite", test))]
enum TokenClaim<T> {
Marked(Instant),
InFlight,
Mismatch,
Hit(T),
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn lookup_or_claim<T: Clone>(
cache: &Mutex<TokenCache<T>>,
token: &str,
hash: u64,
) -> Result<TokenClaim<T>> {
lookup_or_claim_within(cache, token, hash, token_window())
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn lookup_or_claim_within<T: Clone>(
cache: &Mutex<TokenCache<T>>,
token: &str,
hash: u64,
window: Duration,
) -> Result<TokenClaim<T>> {
let mut cache = lock_cache(cache)?;
evict_expired(&mut cache, window);
Ok(match cache.get(token) {
Some((_, cached_hash, _)) if *cached_hash != hash => TokenClaim::Mismatch,
Some((_, _, None)) => TokenClaim::InFlight,
Some((_, _, Some(resp))) => TokenClaim::Hit(resp.clone()),
None => {
let claimed_at = Instant::now();
cache.insert(token.to_string(), (claimed_at, hash, None));
TokenClaim::Marked(claimed_at)
}
})
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn still_ours<T>(cache: &TokenCache<T>, token: &str, claimed_at: Instant) -> bool {
matches!(cache.get(token), Some((at, _, None)) if *at == claimed_at)
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn record_complete<T>(
cache: &Mutex<TokenCache<T>>,
token: &str,
claimed_at: Instant,
hash: u64,
resp: T,
) -> Result<()> {
let mut cache = lock_cache(cache)?;
if still_ours(&cache, token, claimed_at) {
cache.insert(token.to_string(), (claimed_at, hash, Some(resp)));
}
Ok(())
}
#[cfg(any(feature = "wasm-sqlite", test))]
fn clear_claim<T>(cache: &Mutex<TokenCache<T>>, token: &str, claimed_at: Instant) -> Result<()> {
let mut cache = lock_cache(cache)?;
if still_ours(&cache, token, claimed_at) {
cache.remove(token);
}
Ok(())
}
#[cfg(any(feature = "wasm-sqlite", test))]
async fn run_idempotent_async<T, H, F, R>(
cache: &Mutex<TokenCache<T>>,
token: Option<&str>,
hash_input: &H,
execute: F,
replay: R,
) -> Result<T>
where
T: Clone,
H: serde::Serialize,
F: std::future::Future<Output = Result<T>>,
R: FnOnce(&T) -> T,
{
validate_token(token)?;
let Some(token) = token else {
return execute.await;
};
let hash = request_hash(hash_input);
let claimed_at = match lookup_or_claim(cache, token, hash)? {
TokenClaim::Hit(cached) => return Ok(replay(&cached)),
TokenClaim::Mismatch => {
return Err(DynoxideError::IdempotentParameterMismatchException(
"An error occurred (IdempotentParameterMismatchException)".to_string(),
));
}
TokenClaim::InFlight => {
return Err(DynoxideError::InternalServerError(
IN_FLIGHT_MESSAGE.to_string(),
));
}
TokenClaim::Marked(claimed_at) => claimed_at,
};
match execute.await {
Ok(resp) => {
let _ = record_complete(cache, token, claimed_at, hash, resp.clone());
Ok(resp)
}
Err(e) => {
let _ = clear_claim(cache, token, claimed_at);
Err(e)
}
}
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub type RusqliteBackend = storage::Storage;
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub type NativeDatabase = Database<RusqliteBackend>;
#[cfg(feature = "wasm-sqlite")]
pub type WasmDatabase = Database<WasmBridgeBackend>;
#[cfg(feature = "wasm-sqlite")]
pub const WASM_BACKEND: bool = true;
#[cfg(not(feature = "wasm-sqlite"))]
pub const WASM_BACKEND: bool = false;
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
pub struct Database<S = RusqliteBackend> {
inner: Arc<handoff_mutex::HandoffMutex<S>>,
tokens: Arc<TokenCaches>,
vector_lifecycle: Arc<VectorIndexLifecycle>,
}
#[cfg(all(
not(any(feature = "native-sqlite", feature = "_has-encryption")),
feature = "wasm-sqlite"
))]
use async_lock::Mutex as BackendMutex;
#[cfg(all(
not(any(feature = "native-sqlite", feature = "_has-encryption")),
not(feature = "wasm-sqlite")
))]
use std::sync::Mutex as BackendMutex;
#[cfg(not(any(feature = "native-sqlite", feature = "_has-encryption")))]
pub struct Database<S> {
inner: Arc<BackendMutex<S>>,
tokens: Arc<TokenCaches>,
vector_lifecycle: Arc<VectorIndexLifecycle>,
}
impl<S> Clone for Database<S> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
tokens: Arc::clone(&self.tokens),
vector_lifecycle: Arc::clone(&self.vector_lifecycle),
}
}
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
impl Database<RusqliteBackend> {
pub fn new(path: &str) -> Result<Self> {
let storage = storage::Storage::new(path)?;
Ok(Self {
inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
tokens: Arc::new(TokenCaches::new()),
vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
})
}
#[cfg(feature = "_has-encryption")]
pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
if key.len() != 64 || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(DynoxideError::ValidationException(
"Encryption key must be a 64-character hex string (32 bytes)".to_string(),
));
}
let storage = storage::Storage::new_encrypted(path, key)?;
Ok(Self {
inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
tokens: Arc::new(TokenCaches::new()),
vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
})
}
pub fn memory() -> Result<Self> {
let storage = storage::Storage::memory()?;
Ok(Self {
inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
tokens: Arc::new(TokenCaches::new()),
vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
})
}
pub fn memory_with_clock(clock: Arc<dyn storage_backend::Clock>) -> Result<Self> {
let storage = storage::Storage::memory()?.with_clock(clock);
Ok(Self {
inner: Arc::new(handoff_mutex::HandoffMutex::new(storage)),
tokens: Arc::new(TokenCaches::new()),
vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
})
}
pub(crate) fn with_storage<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&storage::Storage) -> Result<T>,
{
let guard = self
.inner
.lock()
.map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
f(&guard)
}
pub(crate) fn with_storage_mut<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&mut storage::Storage) -> Result<T>,
{
let mut guard = self
.inner
.lock()
.map_err(|e| DynoxideError::InternalServerError(format!("Lock poisoned: {e}")))?;
f(&mut guard)
}
pub fn create_table(
&self,
request: actions::create_table::CreateTableRequest,
) -> Result<actions::create_table::CreateTableResponse> {
self.with_storage(|s| pollster::block_on(actions::create_table::execute(s, request)))
}
pub fn delete_table(
&self,
request: actions::delete_table::DeleteTableRequest,
) -> Result<actions::delete_table::DeleteTableResponse> {
self.with_storage(|s| {
pollster::block_on(actions::delete_table::execute(
s,
request,
&self.vector_lifecycle,
))
})
}
pub fn describe_table(
&self,
request: actions::describe_table::DescribeTableRequest,
) -> Result<actions::describe_table::DescribeTableResponse> {
self.with_storage(|s| {
pollster::block_on(actions::describe_table::execute(
s,
request,
&self.vector_lifecycle,
))
})
}
pub fn update_table(
&self,
request: actions::update_table::UpdateTableRequest,
) -> Result<actions::update_table::UpdateTableResponse> {
self.with_storage(|s| {
pollster::block_on(actions::update_table::execute(
s,
request,
&self.vector_lifecycle,
))
})
}
pub fn list_tables(
&self,
request: actions::list_tables::ListTablesRequest,
) -> Result<actions::list_tables::ListTablesResponse> {
self.with_storage(|s| pollster::block_on(actions::list_tables::execute(s, request)))
}
pub fn tag_resource(
&self,
request: actions::tag_resource::TagResourceRequest,
) -> Result<actions::tag_resource::TagResourceResponse> {
self.with_storage(|s| pollster::block_on(actions::tag_resource::execute(s, request)))
}
pub fn untag_resource(
&self,
request: actions::untag_resource::UntagResourceRequest,
) -> Result<actions::untag_resource::UntagResourceResponse> {
self.with_storage(|s| pollster::block_on(actions::untag_resource::execute(s, request)))
}
pub fn list_tags_of_resource(
&self,
request: actions::list_tags_of_resource::ListTagsOfResourceRequest,
) -> Result<actions::list_tags_of_resource::ListTagsOfResourceResponse> {
self.with_storage(|s| {
pollster::block_on(actions::list_tags_of_resource::execute(s, request))
})
}
pub fn put_item(
&self,
request: actions::put_item::PutItemRequest,
) -> Result<actions::put_item::PutItemResponse> {
self.with_storage(|s| pollster::block_on(actions::put_item::execute(s, request)))
}
pub fn get_item(
&self,
request: actions::get_item::GetItemRequest,
) -> Result<actions::get_item::GetItemResponse> {
self.with_storage(|s| pollster::block_on(actions::get_item::execute(s, request)))
}
pub fn delete_item(
&self,
request: actions::delete_item::DeleteItemRequest,
) -> Result<actions::delete_item::DeleteItemResponse> {
self.with_storage(|s| pollster::block_on(actions::delete_item::execute(s, request)))
}
pub fn update_item(
&self,
request: actions::update_item::UpdateItemRequest,
) -> Result<actions::update_item::UpdateItemResponse> {
self.with_storage(|s| pollster::block_on(actions::update_item::execute(s, request)))
}
pub fn batch_get_item(
&self,
request: actions::batch_get_item::BatchGetItemRequest,
) -> Result<actions::batch_get_item::BatchGetItemResponse> {
self.with_storage(|s| pollster::block_on(actions::batch_get_item::execute(s, request)))
}
pub fn batch_write_item(
&self,
request: actions::batch_write_item::BatchWriteItemRequest,
) -> Result<actions::batch_write_item::BatchWriteItemResponse> {
self.with_storage(|s| pollster::block_on(actions::batch_write_item::execute(s, request)))
}
pub fn import_items(
&self,
table_name: &str,
items: Vec<Item>,
options: ImportOptions,
) -> Result<ImportResult> {
self.with_storage(|s| {
pollster::block_on(actions::import_items::execute(
s, table_name, items, &options,
))
})
}
#[cfg(feature = "import")]
pub(crate) fn import_items_fresh(
&self,
table_name: &str,
items: Vec<Item>,
options: ImportOptions,
) -> Result<ImportResult> {
self.with_storage(|s| {
pollster::block_on(actions::import_items::execute_skip_gsi_deletes(
s, table_name, items, &options,
))
})
}
pub fn enable_bulk_loading(&self) -> Result<()> {
self.with_storage(|s| s.enable_bulk_loading())
}
pub fn disable_bulk_loading(&self) -> Result<()> {
self.with_storage(|s| s.disable_bulk_loading())
}
pub fn query(
&self,
request: actions::query::QueryRequest,
) -> Result<actions::query::QueryResponse> {
self.with_storage(|s| pollster::block_on(actions::query::execute(s, request)))
}
pub fn scan(&self, request: actions::scan::ScanRequest) -> Result<actions::scan::ScanResponse> {
self.with_storage(|s| pollster::block_on(actions::scan::execute(s, request)))
}
pub fn search_vectors(
&self,
request: actions::search_vectors::SearchVectorsRequest,
) -> Result<actions::search_vectors::SearchVectorsResponse> {
self.with_storage(|s| {
pollster::block_on(actions::search_vectors::execute(
s,
request,
&self.vector_lifecycle,
))
})
}
pub fn transact_write_items(
&self,
request: actions::transact_write_items::TransactWriteItemsRequest,
) -> Result<actions::transact_write_items::TransactWriteItemsResponse> {
actions::transact_write_items::check_request(&request)?;
run_idempotent(
&self.tokens.transact_write,
request.client_request_token.as_deref(),
&request.transact_items,
|| {
self.with_storage(|s| {
pollster::block_on(actions::transact_write_items::execute_cached(
s,
request.clone(),
))
})
},
|cached| {
actions::transact_write_items::replay_response(
cached,
&request.return_consumed_capacity,
)
},
)
.map(|cached| cached.response)
}
pub fn transact_get_items(
&self,
request: actions::transact_get_items::TransactGetItemsRequest,
) -> Result<actions::transact_get_items::TransactGetItemsResponse> {
self.with_storage(|s| pollster::block_on(actions::transact_get_items::execute(s, request)))
}
pub fn list_streams(
&self,
request: actions::list_streams::ListStreamsRequest,
) -> Result<actions::list_streams::ListStreamsResponse> {
self.with_storage(|s| pollster::block_on(actions::list_streams::execute(s, request)))
}
pub fn describe_stream(
&self,
request: actions::describe_stream::DescribeStreamRequest,
) -> Result<actions::describe_stream::DescribeStreamResponse> {
self.with_storage(|s| pollster::block_on(actions::describe_stream::execute(s, request)))
}
pub fn get_shard_iterator(
&self,
request: actions::get_shard_iterator::GetShardIteratorRequest,
) -> Result<actions::get_shard_iterator::GetShardIteratorResponse> {
self.with_storage(|s| pollster::block_on(actions::get_shard_iterator::execute(s, request)))
}
pub fn get_records(
&self,
request: actions::get_records::GetRecordsRequest,
) -> Result<actions::get_records::GetRecordsResponse> {
self.with_storage(|s| pollster::block_on(actions::get_records::execute(s, request)))
}
pub fn update_time_to_live(
&self,
request: actions::update_time_to_live::UpdateTimeToLiveRequest,
) -> Result<actions::update_time_to_live::UpdateTimeToLiveResponse> {
self.with_storage(|s| pollster::block_on(actions::update_time_to_live::execute(s, request)))
}
pub fn describe_time_to_live(
&self,
request: actions::describe_time_to_live::DescribeTimeToLiveRequest,
) -> Result<actions::describe_time_to_live::DescribeTimeToLiveResponse> {
self.with_storage(|s| {
pollster::block_on(actions::describe_time_to_live::execute(s, request))
})
}
pub fn sweep_ttl(&self) -> Result<usize> {
self.sweep_ttl_by_page(|_| {}, |_| {})
}
fn sweep_ttl_by_page(
&self,
mut in_page: impl FnMut(usize),
mut between_pages: impl FnMut(usize),
) -> Result<usize> {
let mut sweep = self.with_storage(|s| pollster::block_on(ttl::Sweep::begin(s)))?;
let mut page = 0;
loop {
page += 1;
let hand_off = self.with_storage(|s| {
let more = pollster::block_on(sweep.next_page(s))?;
in_page(page);
Ok(more.then(|| self.inner.start_hand_off()))
})?;
let Some(hand_off) = hand_off else {
return Ok(sweep.deleted());
};
hand_off.wait();
between_pages(page);
}
}
pub fn execute_statement(
&self,
request: actions::execute_statement::ExecuteStatementRequest,
) -> Result<actions::execute_statement::ExecuteStatementResponse> {
self.with_storage(|s| pollster::block_on(actions::execute_statement::execute(s, request)))
}
pub fn execute_transaction(
&self,
request: actions::execute_transaction::ExecuteTransactionRequest,
) -> Result<actions::execute_transaction::ExecuteTransactionResponse> {
actions::execute_transaction::check_members(&request)?;
run_idempotent(
&self.tokens.execute_transaction,
request.client_request_token.as_deref(),
&actions::execute_transaction::token_statements(&request.transact_statements),
|| {
self.with_storage(|s| {
pollster::block_on(actions::execute_transaction::execute_cached(
s,
request.clone(),
))
})
},
|cached| {
actions::execute_transaction::replay_response(
cached,
&request.return_consumed_capacity,
)
},
)
.map(|cached| cached.response)
}
pub fn batch_execute_statement(
&self,
request: actions::batch_execute_statement::BatchExecuteStatementRequest,
) -> Result<actions::batch_execute_statement::BatchExecuteStatementResponse> {
self.with_storage(|s| {
pollster::block_on(actions::batch_execute_statement::execute(s, request))
})
}
pub fn touch_cached_at(
&self,
table_name: &str,
pk: &str,
sk: &str,
timestamp: f64,
) -> Result<()> {
self.with_storage(|s| s.touch_cached_at(table_name, pk, sk, timestamp))
}
pub fn get_lru_items(
&self,
table_name: &str,
limit: usize,
) -> Result<Vec<(String, String, i64)>> {
self.with_storage(|s| s.get_lru_items(table_name, limit))
}
pub fn db_path(&self) -> Result<Option<String>> {
self.with_storage(|s| Ok(s.db_path()))
}
pub fn db_size_bytes(&self) -> Result<u64> {
self.with_storage(|s| s.db_size_bytes())
}
pub fn table_count(&self) -> Result<usize> {
self.with_storage(|s| s.table_count())
}
pub fn table_stats(&self) -> Result<Vec<TableStats>> {
self.with_storage(|s| s.table_stats())
}
pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<storage::TableMetadata>> {
self.with_storage(|s| s.get_table_metadata(table_name))
}
pub fn database_info(&self) -> Result<DatabaseInfo> {
self.with_storage(|s| s.database_info())
}
pub fn vacuum(&self) -> Result<()> {
self.with_storage(|s| s.vacuum())
}
pub fn vacuum_into(&self, path: &str) -> Result<()> {
self.with_storage(|s| s.vacuum_into(path))
}
pub fn restore_from(&self, path: &str) -> Result<()> {
self.restore_with(|s| s.restore_from(path))
}
fn restore_with(
&self,
restore: impl FnOnce(&mut storage::Storage) -> Result<()>,
) -> Result<()> {
let mut tokens = self.tokens.lock_all();
self.with_storage_mut(|s| {
restore(s)?;
tokens.clear();
self.vector_lifecycle.forget_all();
Ok(())
})
}
#[cfg(feature = "mcp-server")]
pub(crate) fn backup_to_memory(&self) -> Result<rusqlite::Connection> {
self.with_storage(|s| s.backup_to_memory())
}
#[cfg(feature = "mcp-server")]
pub(crate) fn restore_from_connection(&self, source: &rusqlite::Connection) -> Result<()> {
self.restore_with(|s| s.restore_from_connection(source))
}
}
#[cfg(feature = "wasm-sqlite")]
impl Database<WasmBridgeBackend> {
pub async fn open(name: &str) -> Result<Self> {
Self::open_with(name, false).await
}
pub async fn open_with(name: &str, ephemeral: bool) -> Result<Self> {
let backend = WasmBridgeBackend::open_with(name, ephemeral)
.await
.map_err(DynoxideError::from)?;
Ok(Self {
inner: Arc::new(BackendMutex::new(backend)),
tokens: Arc::new(TokenCaches::new()),
vector_lifecycle: Arc::new(VectorIndexLifecycle::new()),
})
}
pub async fn persistence_mode(&self) -> String {
self.backend().await.persistence_mode().to_string()
}
pub async fn close(&self) -> Result<()> {
self.backend()
.await
.close()
.await
.map_err(DynoxideError::from)
}
pub(crate) async fn backend(&self) -> async_lock::MutexGuard<'_, WasmBridgeBackend> {
self.inner.lock().await
}
pub(crate) fn token_caches(&self) -> &TokenCaches {
&self.tokens
}
pub(crate) fn vector_lifecycle(&self) -> &VectorIndexLifecycle {
&self.vector_lifecycle
}
pub async fn create_table(
&self,
request: actions::create_table::CreateTableRequest,
) -> Result<actions::create_table::CreateTableResponse> {
let backend = self.backend().await;
actions::create_table::execute(&*backend, request).await
}
pub async fn delete_table(
&self,
request: actions::delete_table::DeleteTableRequest,
) -> Result<actions::delete_table::DeleteTableResponse> {
let backend = self.backend().await;
actions::delete_table::execute(&*backend, request, &self.vector_lifecycle).await
}
pub async fn describe_table(
&self,
request: actions::describe_table::DescribeTableRequest,
) -> Result<actions::describe_table::DescribeTableResponse> {
let backend = self.backend().await;
actions::describe_table::execute(&*backend, request, &self.vector_lifecycle).await
}
pub async fn list_tables(
&self,
request: actions::list_tables::ListTablesRequest,
) -> Result<actions::list_tables::ListTablesResponse> {
let backend = self.backend().await;
actions::list_tables::execute(&*backend, request).await
}
pub async fn put_item(
&self,
request: actions::put_item::PutItemRequest,
) -> Result<actions::put_item::PutItemResponse> {
let backend = self.backend().await;
actions::put_item::execute(&*backend, request).await
}
pub async fn get_item(
&self,
request: actions::get_item::GetItemRequest,
) -> Result<actions::get_item::GetItemResponse> {
let backend = self.backend().await;
actions::get_item::execute(&*backend, request).await
}
pub async fn delete_item(
&self,
request: actions::delete_item::DeleteItemRequest,
) -> Result<actions::delete_item::DeleteItemResponse> {
let backend = self.backend().await;
actions::delete_item::execute(&*backend, request).await
}
pub async fn query(
&self,
request: actions::query::QueryRequest,
) -> Result<actions::query::QueryResponse> {
let backend = self.backend().await;
actions::query::execute(&*backend, request).await
}
pub async fn scan(
&self,
request: actions::scan::ScanRequest,
) -> Result<actions::scan::ScanResponse> {
let backend = self.backend().await;
actions::scan::execute(&*backend, request).await
}
pub async fn search_vectors(
&self,
request: actions::search_vectors::SearchVectorsRequest,
) -> Result<actions::search_vectors::SearchVectorsResponse> {
let backend = self.backend().await;
actions::search_vectors::execute(&*backend, request, &self.vector_lifecycle).await
}
}
#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
mod tests {
use super::*;
fn database_with_snapshot(dir: &tempfile::TempDir) -> (Database, String) {
let snapshot = dir.path().join("before.db");
let snapshot = snapshot.to_str().unwrap().to_string();
let db = Database::memory().unwrap();
db.create_table(
serde_json::from_value(serde_json::json!({
"TableName": "tbl",
"BillingMode": "PAY_PER_REQUEST",
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
}))
.unwrap(),
)
.unwrap();
db.vacuum_into(&snapshot).unwrap();
(db, snapshot)
}
fn assert_restore_keeps_cache_then_storage_order<T: Send + 'static>(
db: &Database,
cache: &Mutex<TokenCache<T>>,
restore: impl FnOnce(Database) -> Result<()> + Send + 'static,
) {
let held = cache.lock().unwrap();
let (ready, is_ready) = std::sync::mpsc::channel();
let restorer = {
let db = db.clone();
std::thread::spawn(move || {
let _ = ready.send(());
restore(db)
})
};
is_ready.recv().unwrap();
let (sent, got) = std::sync::mpsc::channel();
{
let db = db.clone();
std::thread::spawn(move || {
let _ = sent.send(db.with_storage(|_| Ok(())).is_ok());
});
}
let storage_step = got.recv_timeout(std::time::Duration::from_secs(5));
let restore_waited = !restorer.is_finished();
drop(held);
assert!(
matches!(storage_step, Ok(true)),
"a transaction holding its token cache must still get the storage lock while a restore waits"
);
assert!(
restore_waited,
"the restore must wait for the token cache the transaction holds"
);
restorer
.join()
.unwrap()
.expect("the restore finishes once the cache is free");
}
fn assert_restore_takes_cache_before_storage<T: Send + 'static>(
db: &Database,
cache: &Mutex<TokenCache<T>>,
restore: impl FnOnce(Database) -> Result<()> + Send + 'static,
) {
let storage = db.inner.lock().unwrap();
let restorer = {
let db = db.clone();
std::thread::spawn(move || restore(db))
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while db.inner.waiting() == 0 {
assert!(
std::time::Instant::now() < deadline,
"the restore never asked for the storage lock"
);
std::thread::yield_now();
}
let cache_was_free = cache.try_lock().is_ok();
drop(storage);
restorer
.join()
.unwrap()
.expect("the restore finishes once storage is free");
assert!(
!cache_was_free,
"a restore must hold the token caches before it waits for storage"
);
}
#[test]
fn a_restore_cannot_deadlock_against_a_tokened_transaction() {
let dir = tempfile::tempdir().unwrap();
let (db, snapshot) = database_with_snapshot(&dir);
let restore = |path: &String| {
let path = path.clone();
move |db: Database| db.restore_from(&path)
};
assert_restore_keeps_cache_then_storage_order(
&db,
&db.tokens.transact_write,
restore(&snapshot),
);
assert_restore_keeps_cache_then_storage_order(
&db,
&db.tokens.execute_transaction,
restore(&snapshot),
);
assert_restore_takes_cache_before_storage(
&db,
&db.tokens.transact_write,
restore(&snapshot),
);
assert_restore_takes_cache_before_storage(
&db,
&db.tokens.execute_transaction,
restore(&snapshot),
);
}
#[cfg(feature = "mcp-server")]
fn connection_restore(db: &Database) -> impl FnOnce(Database) -> Result<()> + Send + 'static {
let copy = db.backup_to_memory().unwrap();
move |db: Database| db.restore_from_connection(©)
}
#[cfg(feature = "mcp-server")]
#[test]
fn a_connection_restore_cannot_deadlock_against_a_tokened_transaction() {
let dir = tempfile::tempdir().unwrap();
let (db, _) = database_with_snapshot(&dir);
assert_restore_keeps_cache_then_storage_order(
&db,
&db.tokens.transact_write,
connection_restore(&db),
);
assert_restore_keeps_cache_then_storage_order(
&db,
&db.tokens.execute_transaction,
connection_restore(&db),
);
assert_restore_takes_cache_before_storage(
&db,
&db.tokens.transact_write,
connection_restore(&db),
);
assert_restore_takes_cache_before_storage(
&db,
&db.tokens.execute_transaction,
connection_restore(&db),
);
}
#[test]
fn a_restore_forgets_cached_transactions_and_vector_windows() {
let dir = tempfile::tempdir().unwrap();
let snapshot = dir.path().join("before.db");
let snapshot = snapshot.to_str().unwrap();
let db = Database::memory().unwrap();
db.create_table(
serde_json::from_value(serde_json::json!({
"TableName": "tbl",
"BillingMode": "PAY_PER_REQUEST",
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
}))
.unwrap(),
)
.unwrap();
db.vacuum_into(snapshot).unwrap();
let put = || {
serde_json::from_value::<actions::transact_write_items::TransactWriteItemsRequest>(
serde_json::json!({
"ClientRequestToken": "same-token",
"TransactItems": [{"Put": {"TableName": "tbl", "Item": {"pk": {"S": "a"}}}}]
}),
)
.unwrap()
};
db.transact_write_items(put()).unwrap();
db.with_storage(|s| {
db.vector_lifecycle.arm(s, "tbl", "v");
Ok(())
})
.unwrap();
assert!(!db.vector_lifecycle.is_empty());
db.restore_from(snapshot).unwrap();
assert!(
db.vector_lifecycle.is_empty(),
"the restored tables' creation windows must not survive"
);
db.transact_write_items(put()).unwrap();
let got = db
.get_item(
serde_json::from_value(serde_json::json!({
"TableName": "tbl",
"Key": {"pk": {"S": "a"}}
}))
.unwrap(),
)
.unwrap();
assert!(
got.item.is_some(),
"the retried transaction must have written"
);
}
#[test]
fn an_unrelated_update_leaves_an_unsorted_stored_set_under_an_index_key_alone() {
let db = Database::memory().unwrap();
db.create_table(
serde_json::from_value(serde_json::json!({
"TableName": "tbl",
"BillingMode": "PAY_PER_REQUEST",
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}]
}))
.unwrap(),
)
.unwrap();
db.put_item(
serde_json::from_value(serde_json::json!({
"TableName": "tbl",
"Item": {"pk": {"S": "k"}, "g": {"SS": ["a", "b"]}}
}))
.unwrap(),
)
.unwrap();
let stored: String = db
.with_storage(|s| {
s.conn().execute(
"UPDATE \"tbl\" SET item_json = replace(item_json, '\"SS\":[\"a\",\"b\"]', '\"SS\":[\"b\",\"a\"]') \
WHERE item_json LIKE '%\"SS\":[\"a\",\"b\"]%'",
[],
)?;
Ok(s.conn()
.query_row("SELECT item_json FROM \"tbl\"", [], |row| row.get(0))?)
})
.unwrap();
assert!(
stored.contains(r#""SS":["b","a"]"#),
"the fixture must hold an unsorted set: {stored}"
);
db.update_table(
serde_json::from_value(serde_json::json!({
"TableName": "tbl",
"AttributeDefinitions": [{"AttributeName": "g", "AttributeType": "S"}],
"GlobalSecondaryIndexUpdates": [{"Create": {
"IndexName": "byG",
"KeySchema": [{"AttributeName": "g", "KeyType": "HASH"}],
"Projection": {"ProjectionType": "ALL"}
}}]
}))
.unwrap(),
)
.unwrap();
db.update_item(
serde_json::from_value(serde_json::json!({
"TableName": "tbl",
"Key": {"pk": {"S": "k"}},
"UpdateExpression": "SET note = :v",
"ExpressionAttributeValues": {":v": {"S": "x"}}
}))
.unwrap(),
)
.expect("an update that doesn't touch g must not re-check g against the index");
}
#[test]
fn test_database_memory() {
let db = Database::memory().unwrap();
let _db2 = db.clone();
}
#[test]
fn test_database_with_storage() {
let db = Database::memory().unwrap();
let tables = db.with_storage(|s| s.list_table_names()).unwrap();
assert!(tables.is_empty());
}
#[test]
fn test_database_thread_safe() {
let db = Database::memory().unwrap();
let db2 = db.clone();
let handle =
std::thread::spawn(move || db2.with_storage(|s| s.list_table_names()).unwrap());
let tables = handle.join().unwrap();
assert!(tables.is_empty());
}
#[test]
fn test_native_database_alias_round_trips() {
let db: NativeDatabase = Database::memory().unwrap();
db.create_table(actions::create_table::CreateTableRequest {
table_name: "tbl".to_string(),
key_schema: vec![types::KeySchemaElement {
attribute_name: "pk".to_string(),
key_type: types::KeyType::HASH,
}],
attribute_definitions: vec![types::AttributeDefinition {
attribute_name: "pk".to_string(),
attribute_type: types::ScalarAttributeType::S,
}],
..Default::default()
})
.unwrap();
let mut item = HashMap::new();
item.insert("pk".to_string(), AttributeValue::S("a".to_string()));
db.put_item(actions::put_item::PutItemRequest {
table_name: "tbl".to_string(),
item,
..Default::default()
})
.unwrap();
let mut key = HashMap::new();
key.insert("pk".to_string(), AttributeValue::S("a".to_string()));
let got = db
.get_item(actions::get_item::GetItemRequest {
table_name: "tbl".to_string(),
key,
..Default::default()
})
.unwrap();
assert_eq!(
got.item.unwrap().get("pk"),
Some(&AttributeValue::S("a".to_string()))
);
}
}
#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
mod ttl_sweep_tests;
#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
mod transaction_storage_error_tests;
#[cfg(test)]
mod idempotency_tests {
use super::*;
use std::cell::Cell;
const KEY: &str = "statements";
const TOKEN: &str = "tok";
fn cache() -> Mutex<TokenCache<u32>> {
Mutex::new(HashMap::new())
}
fn hash() -> u64 {
request_hash(&KEY)
}
fn drive<F>(cache: &Mutex<TokenCache<u32>>, token: Option<&str>, execute: F) -> Result<u32>
where
F: std::future::Future<Output = Result<u32>>,
{
pollster::block_on(run_idempotent_async(cache, token, &KEY, execute, |c| *c))
}
#[test]
fn a_claim_is_visible_to_the_next_caller_and_settles_into_a_hit() {
let cache = cache();
let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
panic!("the first caller should have claimed the token");
};
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::InFlight
));
record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::Hit(7)
));
}
#[test]
fn only_one_caller_can_claim_a_token() {
use std::sync::Barrier;
const N: usize = 16;
let cache = cache();
let barrier = Barrier::new(N);
let marked = std::thread::scope(|scope| {
let handles: Vec<_> = (0..N)
.map(|_| {
scope.spawn(|| {
barrier.wait();
matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::Marked(_)
)
})
})
.collect();
handles
.into_iter()
.map(|h| h.join().unwrap())
.filter(|claimed| *claimed)
.count()
});
assert_eq!(marked, 1);
}
#[test]
fn a_call_whose_claim_expired_does_not_disturb_the_caller_that_took_over() {
let cache = cache();
let TokenClaim::Marked(first) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
panic!("the first caller should have claimed the token");
};
let TokenClaim::Marked(second) =
lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap()
else {
panic!("the expired claim should have been re-issued");
};
assert_ne!(first, second);
record_complete(&cache, TOKEN, first, hash(), 1).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::InFlight
));
clear_claim(&cache, TOKEN, first).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::InFlight
));
record_complete(&cache, TOKEN, second, hash(), 2).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::Hit(2)
));
}
#[test]
fn a_request_hash_ignores_map_key_order() {
use crate::types::AttributeValue;
let item = |n: &str| -> HashMap<String, AttributeValue> {
let inner: HashMap<String, AttributeValue> = (0..16)
.map(|i| (format!("i{i}"), AttributeValue::N(n.to_string())))
.collect();
(0..16)
.map(|i| (format!("k{i}"), AttributeValue::M(inner.clone())))
.collect()
};
for _ in 0..8 {
assert_eq!(
request_hash(&vec![item("1")]),
request_hash(&vec![item("1")])
);
}
assert_ne!(
request_hash(&vec![item("1")]),
request_hash(&vec![item("2")])
);
}
#[test]
fn a_different_request_under_the_same_token_mismatches_in_both_states() {
let other = request_hash(&"different");
let cache = cache();
let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
panic!("expected a free slot to claim");
};
assert!(matches!(
lookup_or_claim(&cache, TOKEN, other).unwrap(),
TokenClaim::Mismatch
));
record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
assert!(matches!(
lookup_or_claim(&cache, TOKEN, other).unwrap(),
TokenClaim::Mismatch
));
}
#[test]
fn the_claim_lands_before_the_call_is_polled() {
let cache = cache();
let claimed_first = Cell::new(false);
let out = drive(&cache, Some(TOKEN), async {
let slots = cache
.try_lock()
.expect("the cache lock must be released before the call runs");
claimed_first.set(matches!(slots.get(TOKEN), Some((_, h, None)) if *h == hash()));
Ok(7)
});
assert_eq!(out.unwrap(), 7);
assert!(claimed_first.get());
}
#[test]
fn a_caller_arriving_under_a_live_claim_does_not_execute() {
let cache = cache();
let second_ran = Cell::new(false);
let out = drive(&cache, Some(TOKEN), async {
let second = drive(&cache, Some(TOKEN), async {
second_ran.set(true);
Ok(0)
});
assert_eq!(second.unwrap_err().to_string(), IN_FLIGHT_MESSAGE);
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::InFlight
));
Ok(1)
});
assert_eq!(out.unwrap(), 1);
assert!(!second_ran.get(), "the second call must not run the work");
assert!(matches!(
lookup_or_claim(&cache, TOKEN, hash()).unwrap(),
TokenClaim::Hit(1)
));
}
#[test]
fn the_synchronous_driver_treats_a_live_claim_as_absent() {
let cache = cache();
lookup_or_claim(&cache, TOKEN, hash()).unwrap();
let out = run_idempotent(&cache, Some(TOKEN), &KEY, || Ok(9), |c| *c).unwrap();
assert_eq!(
out, 9,
"a claimed slot is not treated as a replayable result"
);
}
#[test]
fn a_settled_token_replays_without_re_executing() {
let cache = cache();
let runs = Cell::new(0);
for _ in 0..2 {
let out = drive(&cache, Some(TOKEN), async {
runs.set(runs.get() + 1);
Ok(7)
});
assert_eq!(out.unwrap(), 7);
}
assert_eq!(runs.get(), 1);
}
#[test]
fn a_failed_call_releases_its_claim_so_a_retry_re_executes() {
let cache = cache();
let runs = Cell::new(0);
let first = drive(&cache, Some(TOKEN), async {
runs.set(runs.get() + 1);
Err(DynoxideError::ValidationException("no".into()))
});
assert!(first.is_err());
assert!(
cache.lock().unwrap().is_empty(),
"the claim must be released"
);
let second = drive(&cache, Some(TOKEN), async {
runs.set(runs.get() + 1);
Ok(7)
});
assert_eq!(second.unwrap(), 7);
assert_eq!(runs.get(), 2);
}
#[test]
fn a_claim_left_by_a_dropped_call_expires_rather_than_wedging_the_token() {
let cache = cache();
lookup_or_claim(&cache, TOKEN, hash()).unwrap();
assert!(matches!(
lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
TokenClaim::Marked(_)
));
}
#[test]
fn a_settled_token_stops_replaying_once_it_expires() {
let cache = cache();
let TokenClaim::Marked(at) = lookup_or_claim(&cache, TOKEN, hash()).unwrap() else {
panic!("expected a free slot to claim");
};
record_complete(&cache, TOKEN, at, hash(), 7).unwrap();
assert!(matches!(
lookup_or_claim_within(&cache, TOKEN, hash(), token_window()).unwrap(),
TokenClaim::Hit(7)
));
assert!(matches!(
lookup_or_claim_within(&cache, TOKEN, hash(), Duration::ZERO).unwrap(),
TokenClaim::Marked(_)
));
}
#[test]
fn concurrent_callers_under_one_token_execute_the_work_once() {
use std::sync::Barrier;
use std::sync::atomic::{AtomicUsize, Ordering};
const N: usize = 16;
let cache = cache();
let runs = AtomicUsize::new(0);
let barrier = Barrier::new(N);
let outcomes: Vec<Result<u32>> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..N)
.map(|_| {
scope.spawn(|| {
barrier.wait();
drive(&cache, Some(TOKEN), async {
runs.fetch_add(1, Ordering::SeqCst);
Ok(7)
})
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
assert_eq!(runs.load(Ordering::SeqCst), 1, "the work must run once");
for outcome in outcomes {
match outcome {
Ok(v) => assert_eq!(v, 7),
Err(e) => assert_eq!(e.to_string(), IN_FLIGHT_MESSAGE),
}
}
}
#[test]
fn an_overlong_token_is_rejected_with_dynamodbs_message() {
let token = "x".repeat(MAX_TOKEN_LEN + 1);
let err = drive(&cache(), Some(&token), async { Ok(7) }).unwrap_err();
assert_eq!(
err.to_string(),
format!(
"1 validation error detected: Value '{token}' at 'clientRequestToken' failed to satisfy constraint: Member must have length less than or equal to {MAX_TOKEN_LEN}"
)
);
}
#[test]
fn a_tokenless_call_never_touches_the_cache() {
let cache = cache();
let runs = Cell::new(0);
for _ in 0..2 {
drive(&cache, None, async {
runs.set(runs.get() + 1);
Ok(7)
})
.unwrap();
}
assert_eq!(runs.get(), 2);
assert!(cache.lock().unwrap().is_empty());
}
}