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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions crates/nexum-runtime/src/host/component/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ pub trait StateHandle {
fn delete(&self, key: &str) -> Result<(), StorageError>;
/// Enumerate module-visible keys starting with `prefix`.
fn list_keys(&self, prefix: &str) -> Result<Vec<String>, StorageError>;
/// Whether `key` exists. Default fetches the value; a backend
/// overrides when it can answer without.
fn contains(&self, key: &str) -> Result<bool, StorageError> {
Ok(self.get(key)?.is_some())
}
/// Value byte length, `Ok(None)` when absent. Default fetches the
/// value; on some backends this may be a scan.
fn len(&self, key: &str) -> Result<Option<u64>, StorageError> {
Ok(self.get(key)?.map(|v| v.len() as u64))
}
/// Number of keys starting with `prefix`. Default materialises the
/// key list; on some backends this may be a scan.
fn count(&self, prefix: &str) -> Result<u64, StorageError> {
Ok(self.list_keys(prefix)?.len() as u64)
}
}

impl StateStore for LocalStore {
Expand Down Expand Up @@ -59,4 +74,16 @@ impl StateHandle for ModuleStore {
fn list_keys(&self, prefix: &str) -> Result<Vec<String>, StorageError> {
ModuleStore::list_keys(self, prefix)
}

fn contains(&self, key: &str) -> Result<bool, StorageError> {
ModuleStore::contains(self, key)
}

fn len(&self, key: &str) -> Result<Option<u64>, StorageError> {
ModuleStore::len(self, key)
}

fn count(&self, prefix: &str) -> Result<u64, StorageError> {
ModuleStore::count(self, prefix)
}
}
12 changes: 12 additions & 0 deletions crates/nexum-runtime/src/host/impls/local_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,16 @@ impl<T: RuntimeTypes> nexum::host::local_store::Host for HostState<T> {
async fn list_keys(&mut self, prefix: String) -> Result<Vec<String>, Fault> {
self.store.list_keys(&prefix).map_err(Fault::from)
}

async fn contains(&mut self, key: String) -> Result<bool, Fault> {
self.store.contains(&key).map_err(Fault::from)
}

async fn len(&mut self, key: String) -> Result<Option<u64>, Fault> {
self.store.len(&key).map_err(Fault::from)
}

async fn count(&mut self, prefix: String) -> Result<u64, Fault> {
self.store.count(&prefix).map_err(Fault::from)
}
}
43 changes: 43 additions & 0 deletions crates/nexum-runtime/src/host/local_store_redb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,49 @@ impl ModuleStore {
Ok(value)
}

/// Whether `key` exists, without copying the value out.
pub fn contains(&self, key: &str) -> Result<bool, StorageError> {
let full = self.build_key(key);
let txn = self.db.begin_read().map_err(StorageError::Txn)?;
let table = txn.open_table(TABLE).map_err(StorageError::Table)?;
Ok(table
.get(full.as_slice())
.map_err(StorageError::Storage)?
.is_some())
}

/// Value byte length for `key`, `Ok(None)` when absent. Reads the
/// entry's length in place; the value bytes are never copied out.
pub fn len(&self, key: &str) -> Result<Option<u64>, StorageError> {
let full = self.build_key(key);
let txn = self.db.begin_read().map_err(StorageError::Txn)?;
let table = txn.open_table(TABLE).map_err(StorageError::Table)?;
Ok(table
.get(full.as_slice())
.map_err(StorageError::Storage)?
.map(|v| v.value().len() as u64))
}

/// Number of module-visible keys starting with `prefix`. A bounded
/// B-tree range scan: no key strings are materialised.
pub fn count(&self, prefix: &str) -> Result<u64, StorageError> {
let full_prefix = self.build_key(prefix);
let txn = self.db.begin_read().map_err(StorageError::Txn)?;
let table = txn.open_table(TABLE).map_err(StorageError::Table)?;
let mut count = 0u64;
for entry in table
.range(full_prefix.as_slice()..)
.map_err(StorageError::Storage)?
{
let (k, _v) = entry.map_err(StorageError::Storage)?;
if !k.value().starts_with(&full_prefix) {
break;
}
count += 1;
}
Ok(count)
}

/// Insert or overwrite. Under a quota, charges on-disk cost (prefix, key,
/// value, overhead) and rejects an over-quota write untouched. The commit
/// is fsync-durable.
Expand Down
41 changes: 41 additions & 0 deletions crates/nexum-runtime/src/host/local_store_redb/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,47 @@ fn list_keys_strips_namespace_prefix() {
assert!(keys.iter().all(|k| k.starts_with("posted:")));
}

#[test]
fn contains_answers_without_the_value() {
let (_dir, store) = fresh();
let ms = store.module("twap").unwrap();
ms.set("k", b"v").unwrap();
assert!(ms.contains("k").unwrap());
assert!(!ms.contains("missing").unwrap());
ms.delete("k").unwrap();
assert!(!ms.contains("k").unwrap());
}

#[test]
fn len_reports_value_bytes_or_none() {
let (_dir, store) = fresh();
let ms = store.module("twap").unwrap();
ms.set("empty", b"").unwrap();
ms.set("k", b"abcde").unwrap();
assert_eq!(ms.len("empty").unwrap(), Some(0));
assert_eq!(ms.len("k").unwrap(), Some(5));
assert_eq!(ms.len("missing").unwrap(), None);
}

#[test]
fn count_matches_list_keys_and_respects_namespaces() {
let (_dir, store) = fresh();
let a = store.module("a").unwrap();
let b = store.module("b").unwrap();
a.set("posted:1", b"x").unwrap();
a.set("posted:2", b"y").unwrap();
a.set("other", b"z").unwrap();
b.set("posted:9", b"w").unwrap();
assert_eq!(a.count("posted:").unwrap(), 2);
assert_eq!(a.count("").unwrap(), 3);
assert_eq!(a.count("nope:").unwrap(), 0);
assert_eq!(b.count("posted:").unwrap(), 1);
assert_eq!(
a.count("posted:").unwrap(),
a.list_keys("posted:").unwrap().len() as u64
);
}

#[test]
fn rejects_empty_namespace() {
let (_dir, store) = fresh();
Expand Down
49 changes: 49 additions & 0 deletions crates/nexum-sdk-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ impl LocalStoreHost for MockHost {
fn list_keys(&self, prefix: &str) -> Result<Vec<String>, Fault> {
self.store.list_keys(prefix)
}
fn contains(&self, key: &str) -> Result<bool, Fault> {
self.store.contains(key)
}
fn len(&self, key: &str) -> Result<Option<u64>, Fault> {
// Qualified: MockLocalStore's inherent `len` counts rows.
LocalStoreHost::len(&self.store, key)
}
fn count(&self, prefix: &str) -> Result<u64, Fault> {
self.store.count(prefix)
}
}

impl LoggingHost for MockHost {
Expand Down Expand Up @@ -284,6 +294,23 @@ impl LocalStoreHost for MockLocalStore {
keys.sort();
Ok(keys)
}
fn contains(&self, key: &str) -> Result<bool, Fault> {
self.check_injected_error(key)?;
Ok(self.rows.borrow().contains_key(key))
}
fn len(&self, key: &str) -> Result<Option<u64>, Fault> {
self.check_injected_error(key)?;
Ok(self.rows.borrow().get(key).map(|v| v.len() as u64))
}
fn count(&self, prefix: &str) -> Result<u64, Fault> {
self.check_injected_error(prefix)?;
Ok(self
.rows
.borrow()
.keys()
.filter(|k| k.starts_with(prefix))
.count() as u64)
}
}

// ---------------------------------------------------------------- logging
Expand Down Expand Up @@ -630,6 +657,28 @@ mod tests {
assert_eq!(keys, vec!["watch:a:1", "watch:a:2"]);
}

#[test]
fn local_store_metadata_queries() {
let store = MockLocalStore::default();
store.set("watch:a", b"abc").unwrap();
store.set("watch:b", b"").unwrap();
store.set("posted:1", b"x").unwrap();

assert!(store.contains("watch:a").unwrap());
assert!(!store.contains("missing").unwrap());
assert_eq!(LocalStoreHost::len(&store, "watch:a").unwrap(), Some(3));
assert_eq!(LocalStoreHost::len(&store, "watch:b").unwrap(), Some(0));
assert_eq!(LocalStoreHost::len(&store, "missing").unwrap(), None);
assert_eq!(store.count("watch:").unwrap(), 2);
assert_eq!(store.count("").unwrap(), 3);

// And respect fault injection.
store.fail_on("bad:", Fault::Internal("injected".into()));
assert!(store.contains("bad:k").is_err());
assert!(LocalStoreHost::len(&store, "bad:k").is_err());
assert!(store.count("bad:").is_err());
}

#[test]
fn logging_captures_lines_and_filters_by_level() {
let log = MockLogging::default();
Expand Down
54 changes: 54 additions & 0 deletions crates/nexum-sdk/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,21 @@ pub trait LocalStoreHost {
fn delete(&self, key: &str) -> Result<(), Fault>;
/// Enumerate keys whose raw form starts with `prefix`.
fn list_keys(&self, prefix: &str) -> Result<Vec<String>, Fault>;
/// Whether `key` exists. Default fetches the value; a backend
/// overrides when it can answer without.
fn contains(&self, key: &str) -> Result<bool, Fault> {
Ok(self.get(key)?.is_some())
}
/// Value byte length, `Ok(None)` when absent. Default fetches the
/// value; on some backends this may be a scan.
fn len(&self, key: &str) -> Result<Option<u64>, Fault> {
Ok(self.get(key)?.map(|v| v.len() as u64))
}
/// Number of keys starting with `prefix`. Default materialises the
/// key list; on some backends this may be a scan.
fn count(&self, prefix: &str) -> Result<u64, Fault> {
Ok(self.list_keys(prefix)?.len() as u64)
}
}

/// `nexum:host/logging` - structured runtime logs.
Expand Down Expand Up @@ -282,6 +297,45 @@ mod tests {
assert_eq!(boxed.label(), "timeout");
}

#[test]
fn local_store_metadata_defaults_derive_from_required_methods() {
use super::LocalStoreHost;

/// Two fixed rows; only the four required methods are written.
struct TwoRows;
impl LocalStoreHost for TwoRows {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Fault> {
Ok(match key {
"a" => Some(b"abc".to_vec()),
"b" => Some(Vec::new()),
_ => None,
})
}
fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> {
Ok(())
}
fn delete(&self, _: &str) -> Result<(), Fault> {
Ok(())
}
fn list_keys(&self, prefix: &str) -> Result<Vec<String>, Fault> {
Ok(["a", "b"]
.iter()
.filter(|k| k.starts_with(prefix))
.map(|k| (*k).to_owned())
.collect())
}
}

assert!(TwoRows.contains("a").unwrap());
assert!(!TwoRows.contains("missing").unwrap());
assert_eq!(TwoRows.len("a").unwrap(), Some(3));
assert_eq!(TwoRows.len("b").unwrap(), Some(0));
assert_eq!(TwoRows.len("missing").unwrap(), None);
assert_eq!(TwoRows.count("").unwrap(), 2);
assert_eq!(TwoRows.count("a").unwrap(), 1);
assert_eq!(TwoRows.count("z").unwrap(), 0);
}

#[test]
fn chain_error_recovers_embedded_fault() {
let fault = ChainError::Fault(Fault::Timeout);
Expand Down
12 changes: 12 additions & 0 deletions crates/nexum-sdk/src/wit_bindgen_macro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ macro_rules! bind_host_via_wit_bindgen {
{
nexum::host::local_store::list_keys(prefix).map_err(convert_fault)
}
fn contains(&self, key: &str) -> ::core::result::Result<bool, $crate::host::Fault> {
nexum::host::local_store::contains(key).map_err(convert_fault)
}
fn len(
&self,
key: &str,
) -> ::core::result::Result<::core::option::Option<u64>, $crate::host::Fault> {
nexum::host::local_store::len(key).map_err(convert_fault)
}
fn count(&self, prefix: &str) -> ::core::result::Result<u64, $crate::host::Fault> {
nexum::host::local_store::count(prefix).map_err(convert_fault)
}
}

impl $crate::host::LoggingHost for WitBindgenHost {
Expand Down
10 changes: 10 additions & 0 deletions crates/shepherd-sdk-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ impl LocalStoreHost for MockHost {
fn list_keys(&self, prefix: &str) -> Result<Vec<String>, Fault> {
self.store.list_keys(prefix)
}
fn contains(&self, key: &str) -> Result<bool, Fault> {
self.store.contains(key)
}
fn len(&self, key: &str) -> Result<Option<u64>, Fault> {
// Qualified: MockLocalStore's inherent `len` counts rows.
LocalStoreHost::len(&self.store, key)
}
fn count(&self, prefix: &str) -> Result<u64, Fault> {
self.store.count(prefix)
}
}

impl CowApiHost for MockHost {
Expand Down
11 changes: 11 additions & 0 deletions wit/nexum-host/local-store.wit
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,15 @@ interface local-store {

/// List all keys matching a prefix. Empty prefix returns all keys.
list-keys: func(prefix: string) -> result<list<string>, fault>;

/// Whether the key exists, without transferring the value.
contains: func(key: string) -> result<bool, fault>;

/// Value byte length, none if the key is absent, without
/// transferring the value. On some backends this may be a scan.
len: func(key: string) -> result<option<u64>, fault>;

/// Number of keys matching a prefix, without materialising the key
/// list. On some backends this may be a scan.
count: func(prefix: string) -> result<u64, fault>;
}
Loading