From 14b0cc15f87978f56f39e50deb80cdf188e1d046 Mon Sep 17 00:00:00 2001 From: itowlson Date: Fri, 3 May 2024 12:55:08 +1200 Subject: [PATCH] Support wasi-keyvalue for key-value store access Signed-off-by: itowlson --- crates/factor-key-value/src/host.rs | 108 +++++++++++++++- crates/factor-key-value/src/lib.rs | 1 + crates/world/src/lib.rs | 1 + .../tests/wasi-key-value/spin.toml | 14 ++ tests/test-components/components/Cargo.lock | 8 ++ .../components/wasi-key-value/Cargo.toml | 11 ++ .../components/wasi-key-value/README.md | 10 ++ .../components/wasi-key-value/src/lib.rs | 51 ++++++++ wit/deps/keyvalue-2024-05-03/atomic.wit | 22 ++++ wit/deps/keyvalue-2024-05-03/batch.wit | 63 +++++++++ wit/deps/keyvalue-2024-05-03/store.wit | 122 ++++++++++++++++++ wit/deps/keyvalue-2024-05-03/watch.wit | 16 +++ wit/deps/keyvalue-2024-05-03/world.wit | 26 ++++ wit/deps/spin@3.0.0/world.wit | 1 + 14 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 tests/runtime-tests/tests/wasi-key-value/spin.toml create mode 100644 tests/test-components/components/wasi-key-value/Cargo.toml create mode 100644 tests/test-components/components/wasi-key-value/README.md create mode 100644 tests/test-components/components/wasi-key-value/src/lib.rs create mode 100644 wit/deps/keyvalue-2024-05-03/atomic.wit create mode 100644 wit/deps/keyvalue-2024-05-03/batch.wit create mode 100644 wit/deps/keyvalue-2024-05-03/store.wit create mode 100644 wit/deps/keyvalue-2024-05-03/watch.wit create mode 100644 wit/deps/keyvalue-2024-05-03/world.wit diff --git a/crates/factor-key-value/src/host.rs b/crates/factor-key-value/src/host.rs index b8dbb95968..9bcbb04a5b 100644 --- a/crates/factor-key-value/src/host.rs +++ b/crates/factor-key-value/src/host.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use spin_core::{async_trait, wasmtime::component::Resource}; use spin_resource_table::Table; use spin_world::v2::key_value; +use spin_world::wasi::keyvalue as wasi_keyvalue; use std::{collections::HashSet, sync::Arc}; use tracing::{instrument, Level}; @@ -55,13 +56,22 @@ impl KeyValueDispatch { } } - pub fn get_store(&self, store: Resource) -> anyhow::Result<&Arc> { + pub fn get_store(&self, store: Resource) -> anyhow::Result<&Arc> { self.stores.get(store.rep()).context("invalid store") } pub fn allowed_stores(&self) -> &HashSet { &self.allowed_stores } + + pub fn get_store_wasi( + &self, + store: Resource, + ) -> Result<&Arc, wasi_keyvalue::store::Error> { + self.stores + .get(store.rep()) + .ok_or(wasi_keyvalue::store::Error::NoSuchStore) + } } #[async_trait] @@ -141,6 +151,102 @@ impl key_value::HostStore for KeyValueDispatch { } } +fn to_wasi_err(e: Error) -> wasi_keyvalue::store::Error { + match e { + Error::AccessDenied => wasi_keyvalue::store::Error::AccessDenied, + Error::NoSuchStore => wasi_keyvalue::store::Error::NoSuchStore, + Error::StoreTableFull => wasi_keyvalue::store::Error::Other("store table full".to_string()), + Error::Other(msg) => wasi_keyvalue::store::Error::Other(msg), + } +} + +#[async_trait] +impl wasi_keyvalue::store::Host for KeyValueDispatch { + async fn open( + &mut self, + identifier: String, + ) -> Result, wasi_keyvalue::store::Error> { + if self.allowed_stores.contains(&identifier) { + let store = self + .stores + .push(self.manager.get(&identifier).await.map_err(to_wasi_err)?) + .map_err(|()| wasi_keyvalue::store::Error::Other("store table full".to_string()))?; + Ok(Resource::new_own(store)) + } else { + Err(wasi_keyvalue::store::Error::AccessDenied) + } + } + + fn convert_error( + &mut self, + error: spin_world::wasi::keyvalue::store::Error, + ) -> std::result::Result { + Ok(error) + } +} + +use wasi_keyvalue::store::Bucket; +#[async_trait] +impl wasi_keyvalue::store::HostBucket for KeyValueDispatch { + async fn get( + &mut self, + self_: Resource, + key: String, + ) -> Result>, wasi_keyvalue::store::Error> { + let store = self.get_store_wasi(self_)?; + store.get(&key).await.map_err(to_wasi_err) + } + + async fn set( + &mut self, + self_: Resource, + key: String, + value: Vec, + ) -> Result<(), wasi_keyvalue::store::Error> { + let store = self.get_store_wasi(self_)?; + store.set(&key, &value).await.map_err(to_wasi_err) + } + + async fn delete( + &mut self, + self_: Resource, + key: String, + ) -> Result<(), wasi_keyvalue::store::Error> { + let store = self.get_store_wasi(self_)?; + store.delete(&key).await.map_err(to_wasi_err) + } + + async fn exists( + &mut self, + self_: Resource, + key: String, + ) -> Result { + let store = self.get_store_wasi(self_)?; + store.exists(&key).await.map_err(to_wasi_err) + } + + async fn list_keys( + &mut self, + self_: Resource, + cursor: Option, + ) -> Result { + if cursor.unwrap_or_default() != 0 { + return Err(wasi_keyvalue::store::Error::Other( + "list_keys: cursor not supported".to_owned(), + )); + } + + let store = self.get_store_wasi(self_)?; + let keys = store.get_keys().await.map_err(to_wasi_err)?; + Ok(wasi_keyvalue::store::KeyResponse { keys, cursor: None }) + } + + async fn drop(&mut self, rep: Resource) -> anyhow::Result<()> { + self.stores.remove(rep.rep()); + Ok(()) + } +} + pub fn log_error(err: impl std::fmt::Debug) -> Error { tracing::warn!("key-value error: {err:?}"); Error::Other(format!("{err:?}")) diff --git a/crates/factor-key-value/src/lib.rs b/crates/factor-key-value/src/lib.rs index 685766b51c..259c751027 100644 --- a/crates/factor-key-value/src/lib.rs +++ b/crates/factor-key-value/src/lib.rs @@ -40,6 +40,7 @@ impl Factor for KeyValueFactor { fn init(&mut self, mut ctx: InitContext) -> anyhow::Result<()> { ctx.link_bindings(spin_world::v1::key_value::add_to_linker)?; ctx.link_bindings(spin_world::v2::key_value::add_to_linker)?; + ctx.link_bindings(spin_world::wasi::keyvalue::store::add_to_linker)?; Ok(()) } diff --git a/crates/world/src/lib.rs b/crates/world/src/lib.rs index 0ecb57ee1d..466906195c 100644 --- a/crates/world/src/lib.rs +++ b/crates/world/src/lib.rs @@ -28,6 +28,7 @@ wasmtime::component::bindgen!({ "fermyon:spin/sqlite@2.0.0/error" => v2::sqlite::Error, "fermyon:spin/sqlite/error" => v1::sqlite::Error, "fermyon:spin/variables@2.0.0/error" => v2::variables::Error, + "wasi:keyvalue/store/error" => wasi::keyvalue::store::Error, }, trappable_imports: true, }); diff --git a/tests/runtime-tests/tests/wasi-key-value/spin.toml b/tests/runtime-tests/tests/wasi-key-value/spin.toml new file mode 100644 index 0000000000..53cda06cec --- /dev/null +++ b/tests/runtime-tests/tests/wasi-key-value/spin.toml @@ -0,0 +1,14 @@ +spin_manifest_version = 2 + +[application] +name = "wasi-key-value" +authors = ["Fermyon Engineering "] +version = "0.1.0" + +[[trigger.http]] +route = "/" +component = "test" + +[component.test] +source = "%{source=wasi-key-value}" +key_value_stores = ["default"] diff --git a/tests/test-components/components/Cargo.lock b/tests/test-components/components/Cargo.lock index 3c6212dd85..6bba363f16 100644 --- a/tests/test-components/components/Cargo.lock +++ b/tests/test-components/components/Cargo.lock @@ -931,6 +931,14 @@ dependencies = [ "wit-bindgen 0.16.0", ] +[[package]] +name = "wasi-key-value" +version = "0.1.0" +dependencies = [ + "helper", + "wit-bindgen 0.16.0", +] + [[package]] name = "wasm-encoder" version = "0.36.2" diff --git a/tests/test-components/components/wasi-key-value/Cargo.toml b/tests/test-components/components/wasi-key-value/Cargo.toml new file mode 100644 index 0000000000..0bd198ce14 --- /dev/null +++ b/tests/test-components/components/wasi-key-value/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "wasi-key-value" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +helper = { path = "../../helper" } +wit-bindgen = "0.16.0" diff --git a/tests/test-components/components/wasi-key-value/README.md b/tests/test-components/components/wasi-key-value/README.md new file mode 100644 index 0000000000..2cff701082 --- /dev/null +++ b/tests/test-components/components/wasi-key-value/README.md @@ -0,0 +1,10 @@ +# Key Value + +Tests the key/value interface. + +## Expectations + +This test component expects the following to be true: +* It is given permission to open a connection to the "default" store. +* It does not have permission to access a store named "forbidden". +* It is empty diff --git a/tests/test-components/components/wasi-key-value/src/lib.rs b/tests/test-components/components/wasi-key-value/src/lib.rs new file mode 100644 index 0000000000..e63e268461 --- /dev/null +++ b/tests/test-components/components/wasi-key-value/src/lib.rs @@ -0,0 +1,51 @@ +use helper::{ensure_matches, ensure_ok}; + +use bindings::wasi::keyvalue::store::{Error, open, KeyResponse}; + +helper::define_component!(Component); + +impl Component { + fn main() -> Result<(), String> { + + ensure_matches!(open("forbidden"), Err(Error::AccessDenied)); + + let store = ensure_ok!(open("default")); + + // Ensure nothing set in `bar` key + ensure_ok!(store.delete("bar")); + ensure_matches!(store.exists("bar"), Ok(false)); + ensure_matches!(store.get("bar"), Ok(None)); + ensure_matches!(keys(&store.list_keys(None)), Ok(&[])); + + // Set `bar` key + ensure_ok!(store.set("bar", b"baz")); + ensure_matches!(store.exists("bar"), Ok(true)); + ensure_matches!(store.get("bar"), Ok(Some(v)) if v == b"baz"); + ensure_matches!(keys(&store.list_keys(None)), Ok([bar]) if bar == "bar"); + ensure_matches!(keys(&store.list_keys(Some(0))), Ok([bar]) if bar == "bar"); + + // Override `bar` key + ensure_ok!(store.set("bar", b"wow")); + ensure_matches!(store.exists("bar"), Ok(true)); + ensure_matches!(store.get("bar"), Ok(Some(wow)) if wow == b"wow"); + ensure_matches!(keys(&store.list_keys(None)), Ok([bar]) if bar == "bar"); + + // Set another key + ensure_ok!(store.set("qux", b"yay")); + ensure_matches!(keys(&store.list_keys(None)), Ok(c) if c.len() == 2 && c.contains(&"bar".into()) && c.contains(&"qux".into())); + + // Delete everything + ensure_ok!(store.delete("bar")); + ensure_ok!(store.delete("bar")); + ensure_ok!(store.delete("qux")); + ensure_matches!(store.exists("bar"), Ok(false)); + ensure_matches!(store.get("qux"), Ok(None)); + ensure_matches!(keys(&store.list_keys(None)), Ok(&[])); + + Ok(()) + } +} + +fn keys(res: &Result) -> Result<&[String], &E> { + res.as_ref().map(|kr| kr.keys.as_slice()) +} diff --git a/wit/deps/keyvalue-2024-05-03/atomic.wit b/wit/deps/keyvalue-2024-05-03/atomic.wit new file mode 100644 index 0000000000..059efc4889 --- /dev/null +++ b/wit/deps/keyvalue-2024-05-03/atomic.wit @@ -0,0 +1,22 @@ +/// A keyvalue interface that provides atomic operations. +/// +/// Atomic operations are single, indivisible operations. When a fault causes an atomic operation to +/// fail, it will appear to the invoker of the atomic operation that the action either completed +/// successfully or did nothing at all. +/// +/// Please note that this interface is bare functions that take a reference to a bucket. This is to +/// get around the current lack of a way to "extend" a resource with additional methods inside of +/// wit. Future version of the interface will instead extend these methods on the base `bucket` +/// resource. +interface atomics { + use store.{bucket, error}; + + /// Atomically increment the value associated with the key in the store by the given delta. It + /// returns the new value. + /// + /// If the key does not exist in the store, it creates a new key-value pair with the value set + /// to the given delta. + /// + /// If any other error occurs, it returns an `Err(error)`. + increment: func(bucket: borrow, key: string, delta: u64) -> result; +} \ No newline at end of file diff --git a/wit/deps/keyvalue-2024-05-03/batch.wit b/wit/deps/keyvalue-2024-05-03/batch.wit new file mode 100644 index 0000000000..70c05feb91 --- /dev/null +++ b/wit/deps/keyvalue-2024-05-03/batch.wit @@ -0,0 +1,63 @@ +/// A keyvalue interface that provides batch operations. +/// +/// A batch operation is an operation that operates on multiple keys at once. +/// +/// Batch operations are useful for reducing network round-trip time. For example, if you want to +/// get the values associated with 100 keys, you can either do 100 get operations or you can do 1 +/// batch get operation. The batch operation is faster because it only needs to make 1 network call +/// instead of 100. +/// +/// A batch operation does not guarantee atomicity, meaning that if the batch operation fails, some +/// of the keys may have been modified and some may not. +/// +/// This interface does has the same consistency guarantees as the `store` interface, meaning that +/// you should be able to "read your writes." +/// +/// Please note that this interface is bare functions that take a reference to a bucket. This is to +/// get around the current lack of a way to "extend" a resource with additional methods inside of +/// wit. Future version of the interface will instead extend these methods on the base `bucket` +/// resource. +interface batch { + use store.{bucket, error}; + + /// Get the key-value pairs associated with the keys in the store. It returns a list of + /// key-value pairs. + /// + /// If any of the keys do not exist in the store, it returns a `none` value for that pair in the + /// list. + /// + /// MAY show an out-of-date value if there are concurrent writes to the store. + /// + /// If any other error occurs, it returns an `Err(error)`. + get-many: func(bucket: borrow, keys: list) -> result>>>, error>; + + /// Set the values associated with the keys in the store. If the key already exists in the + /// store, it overwrites the value. + /// + /// Note that the key-value pairs are not guaranteed to be set in the order they are provided. + /// + /// If any of the keys do not exist in the store, it creates a new key-value pair. + /// + /// If any other error occurs, it returns an `Err(error)`. When an error occurs, it does not + /// rollback the key-value pairs that were already set. Thus, this batch operation does not + /// guarantee atomicity, implying that some key-value pairs could be set while others might + /// fail. + /// + /// Other concurrent operations may also be able to see the partial results. + set-many: func(bucket: borrow, key-values: list>>) -> result<_, error>; + + /// Delete the key-value pairs associated with the keys in the store. + /// + /// Note that the key-value pairs are not guaranteed to be deleted in the order they are + /// provided. + /// + /// If any of the keys do not exist in the store, it skips the key. + /// + /// If any other error occurs, it returns an `Err(error)`. When an error occurs, it does not + /// rollback the key-value pairs that were already deleted. Thus, this batch operation does not + /// guarantee atomicity, implying that some key-value pairs could be deleted while others might + /// fail. + /// + /// Other concurrent operations may also be able to see the partial results. + delete-many: func(bucket: borrow, keys: list) -> result<_, error>; +} diff --git a/wit/deps/keyvalue-2024-05-03/store.wit b/wit/deps/keyvalue-2024-05-03/store.wit new file mode 100644 index 0000000000..3354ea2f32 --- /dev/null +++ b/wit/deps/keyvalue-2024-05-03/store.wit @@ -0,0 +1,122 @@ +/// A keyvalue interface that provides eventually consistent key-value operations. +/// +/// Each of these operations acts on a single key-value pair. +/// +/// The value in the key-value pair is defined as a `u8` byte array and the intention is that it is +/// the common denominator for all data types defined by different key-value stores to handle data, +/// ensuring compatibility between different key-value stores. Note: the clients will be expecting +/// serialization/deserialization overhead to be handled by the key-value store. The value could be +/// a serialized object from JSON, HTML or vendor-specific data types like AWS S3 objects. +/// +/// Data consistency in a key value store refers to the guarantee that once a write operation +/// completes, all subsequent read operations will return the value that was written. +/// +/// Any implementation of this interface must have enough consistency to guarantee "reading your +/// writes." In particular, this means that the client should never get a value that is older than +/// the one it wrote, but it MAY get a newer value if one was written around the same time. These +/// guarantees only apply to the same client (which will likely be provided by the host or an +/// external capability of some kind). In this context a "client" is referring to the caller or +/// guest that is consuming this interface. Once a write request is committed by a specific client, +/// all subsequent read requests by the same client will reflect that write or any subsequent +/// writes. Another client running in a different context may or may not immediately see the result +/// due to the replication lag. As an example of all of this, if a value at a given key is A, and +/// the client writes B, then immediately reads, it should get B. If something else writes C in +/// quick succession, then the client may get C. However, a client running in a separate context may +/// still see A or B +interface store { + /// The set of errors which may be raised by functions in this package + variant error { + /// The host does not recognize the store identifier requested. + no-such-store, + + /// The requesting component does not have access to the specified store + /// (which may or may not exist). + access-denied, + + /// Some implementation-specific error has occurred (e.g. I/O) + other(string) + } + + /// A response to a `list-keys` operation. + record key-response { + /// The list of keys returned by the query. + keys: list, + /// The continuation token to use to fetch the next page of keys. If this is `null`, then + /// there are no more keys to fetch. + cursor: option + } + + /// Get the bucket with the specified identifier. + /// + /// `identifier` must refer to a bucket provided by the host. + /// + /// `error::no-such-store` will be raised if the `identifier` is not recognized. + open: func(identifier: string) -> result; + + /// A bucket is a collection of key-value pairs. Each key-value pair is stored as a entry in the + /// bucket, and the bucket itself acts as a collection of all these entries. + /// + /// It is worth noting that the exact terminology for bucket in key-value stores can very + /// depending on the specific implementation. For example: + /// + /// 1. Amazon DynamoDB calls a collection of key-value pairs a table + /// 2. Redis has hashes, sets, and sorted sets as different types of collections + /// 3. Cassandra calls a collection of key-value pairs a column family + /// 4. MongoDB calls a collection of key-value pairs a collection + /// 5. Riak calls a collection of key-value pairs a bucket + /// 6. Memcached calls a collection of key-value pairs a slab + /// 7. Azure Cosmos DB calls a collection of key-value pairs a container + /// + /// In this interface, we use the term `bucket` to refer to a collection of key-value pairs + resource bucket { + /// Get the value associated with the specified `key` + /// + /// The value is returned as an option. If the key-value pair exists in the + /// store, it returns `Ok(value)`. If the key does not exist in the + /// store, it returns `Ok(none)`. + /// + /// If any other error occurs, it returns an `Err(error)`. + get: func(key: string) -> result>, error>; + + /// Set the value associated with the key in the store. If the key already + /// exists in the store, it overwrites the value. + /// + /// If the key does not exist in the store, it creates a new key-value pair. + /// + /// If any other error occurs, it returns an `Err(error)`. + set: func(key: string, value: list) -> result<_, error>; + + /// Delete the key-value pair associated with the key in the store. + /// + /// If the key does not exist in the store, it does nothing. + /// + /// If any other error occurs, it returns an `Err(error)`. + delete: func(key: string) -> result<_, error>; + + /// Check if the key exists in the store. + /// + /// If the key exists in the store, it returns `Ok(true)`. If the key does + /// not exist in the store, it returns `Ok(false)`. + /// + /// If any other error occurs, it returns an `Err(error)`. + exists: func(key: string) -> result; + + /// Get all the keys in the store with an optional cursor (for use in pagination). It + /// returns a list of keys. Please note that for most KeyValue implementations, this is a + /// can be a very expensive operation and so it should be used judiciously. Implementations + /// can return any number of keys in a single response, but they should never attempt to + /// send more data than is reasonable (i.e. on a small edge device, this may only be a few + /// KB, while on a large machine this could be several MB). Any response should also return + /// a cursor that can be used to fetch the next page of keys. See the `key-response` record + /// for more information. + /// + /// Note that the keys are not guaranteed to be returned in any particular order. + /// + /// If the store is empty, it returns an empty list. + /// + /// MAY show an out-of-date list of keys if there are concurrent writes to the store. + /// + /// If any error occurs, it returns an `Err(error)`. + list-keys: func(cursor: option) -> result; + } +} diff --git a/wit/deps/keyvalue-2024-05-03/watch.wit b/wit/deps/keyvalue-2024-05-03/watch.wit new file mode 100644 index 0000000000..ff13f7523e --- /dev/null +++ b/wit/deps/keyvalue-2024-05-03/watch.wit @@ -0,0 +1,16 @@ +/// A keyvalue interface that provides watch operations. +/// +/// This interface is used to provide event-driven mechanisms to handle +/// keyvalue changes. +interface watcher { + /// A keyvalue interface that provides handle-watch operations. + use store.{bucket}; + + /// Handle the `set` event for the given bucket and key. It includes a reference to the `bucket` + /// that can be used to interact with the store. + on-set: func(bucket: bucket, key: string, value: list); + + /// Handle the `delete` event for the given bucket and key. It includes a reference to the + /// `bucket` that can be used to interact with the store. + on-delete: func(bucket: bucket, key: string); +} \ No newline at end of file diff --git a/wit/deps/keyvalue-2024-05-03/world.wit b/wit/deps/keyvalue-2024-05-03/world.wit new file mode 100644 index 0000000000..066148c1fa --- /dev/null +++ b/wit/deps/keyvalue-2024-05-03/world.wit @@ -0,0 +1,26 @@ +package wasi:keyvalue@0.2.0-draft; + +/// The `wasi:keyvalue/imports` world provides common APIs for interacting with key-value stores. +/// Components targeting this world will be able to do: +/// +/// 1. CRUD (create, read, update, delete) operations on key-value stores. +/// 2. Atomic `increment` and CAS (compare-and-swap) operations. +/// 3. Batch operations that can reduce the number of round trips to the network. +world imports { + /// The `store` capability allows the component to perform eventually consistent operations on + /// the key-value store. + import store; + + /// The `atomic` capability allows the component to perform atomic / `increment` and CAS + /// (compare-and-swap) operations. + import atomics; + + /// The `batch` capability allows the component to perform eventually consistent batch + /// operations that can reduce the number of round trips to the network. + import batch; +} + +world watch-service { + include imports; + export watcher; +} \ No newline at end of file diff --git a/wit/deps/spin@3.0.0/world.wit b/wit/deps/spin@3.0.0/world.wit index a959cb002c..1d8f3de02b 100644 --- a/wit/deps/spin@3.0.0/world.wit +++ b/wit/deps/spin@3.0.0/world.wit @@ -10,6 +10,7 @@ world http-trigger { world platform { include wasi:cli/imports@0.2.0; import wasi:http/outgoing-handler@0.2.0; + import wasi:keyvalue/store@0.2.0-draft; import fermyon:spin/llm@2.0.0; import fermyon:spin/redis@2.0.0; import fermyon:spin/mqtt@2.0.0;