Skip to content

Commit 514500a

Browse files
committed
Support wasi-keyvalue for key-value store access
Signed-off-by: itowlson <[email protected]>
1 parent 927b2c4 commit 514500a

File tree

14 files changed

+453
-1
lines changed

14 files changed

+453
-1
lines changed

crates/factor-key-value/src/host.rs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
22
use spin_core::{async_trait, wasmtime::component::Resource};
33
use spin_resource_table::Table;
44
use spin_world::v2::key_value;
5+
use spin_world::wasi::keyvalue as wasi_keyvalue;
56
use std::{collections::HashSet, sync::Arc};
67
use tracing::{instrument, Level};
78

@@ -55,13 +56,22 @@ impl KeyValueDispatch {
5556
}
5657
}
5758

58-
pub fn get_store(&self, store: Resource<key_value::Store>) -> anyhow::Result<&Arc<dyn Store>> {
59+
pub fn get_store<T: 'static>(&self, store: Resource<T>) -> anyhow::Result<&Arc<dyn Store>> {
5960
self.stores.get(store.rep()).context("invalid store")
6061
}
6162

6263
pub fn allowed_stores(&self) -> &HashSet<String> {
6364
&self.allowed_stores
6465
}
66+
67+
pub fn get_store_wasi<T: 'static>(
68+
&self,
69+
store: Resource<T>,
70+
) -> Result<&Arc<dyn Store>, wasi_keyvalue::store::Error> {
71+
self.stores
72+
.get(store.rep())
73+
.ok_or(wasi_keyvalue::store::Error::NoSuchStore)
74+
}
6575
}
6676

6777
#[async_trait]
@@ -141,6 +151,102 @@ impl key_value::HostStore for KeyValueDispatch {
141151
}
142152
}
143153

154+
fn to_wasi_err(e: Error) -> wasi_keyvalue::store::Error {
155+
match e {
156+
Error::AccessDenied => wasi_keyvalue::store::Error::AccessDenied,
157+
Error::NoSuchStore => wasi_keyvalue::store::Error::NoSuchStore,
158+
Error::StoreTableFull => wasi_keyvalue::store::Error::Other("store table full".to_string()),
159+
Error::Other(msg) => wasi_keyvalue::store::Error::Other(msg),
160+
}
161+
}
162+
163+
#[async_trait]
164+
impl wasi_keyvalue::store::Host for KeyValueDispatch {
165+
async fn open(
166+
&mut self,
167+
identifier: String,
168+
) -> Result<Resource<wasi_keyvalue::store::Bucket>, wasi_keyvalue::store::Error> {
169+
if self.allowed_stores.contains(&identifier) {
170+
let store = self
171+
.stores
172+
.push(self.manager.get(&identifier).await.map_err(to_wasi_err)?)
173+
.map_err(|()| wasi_keyvalue::store::Error::Other("store table full".to_string()))?;
174+
Ok(Resource::new_own(store))
175+
} else {
176+
Err(wasi_keyvalue::store::Error::AccessDenied)
177+
}
178+
}
179+
180+
fn convert_error(
181+
&mut self,
182+
error: spin_world::wasi::keyvalue::store::Error,
183+
) -> std::result::Result<spin_world::wasi::keyvalue::store::Error, anyhow::Error> {
184+
Ok(error)
185+
}
186+
}
187+
188+
use wasi_keyvalue::store::Bucket;
189+
#[async_trait]
190+
impl wasi_keyvalue::store::HostBucket for KeyValueDispatch {
191+
async fn get(
192+
&mut self,
193+
self_: Resource<Bucket>,
194+
key: String,
195+
) -> Result<Option<Vec<u8>>, wasi_keyvalue::store::Error> {
196+
let store = self.get_store_wasi(self_)?;
197+
store.get(&key).await.map_err(to_wasi_err)
198+
}
199+
200+
async fn set(
201+
&mut self,
202+
self_: Resource<Bucket>,
203+
key: String,
204+
value: Vec<u8>,
205+
) -> Result<(), wasi_keyvalue::store::Error> {
206+
let store = self.get_store_wasi(self_)?;
207+
store.set(&key, &value).await.map_err(to_wasi_err)
208+
}
209+
210+
async fn delete(
211+
&mut self,
212+
self_: Resource<Bucket>,
213+
key: String,
214+
) -> Result<(), wasi_keyvalue::store::Error> {
215+
let store = self.get_store_wasi(self_)?;
216+
store.delete(&key).await.map_err(to_wasi_err)
217+
}
218+
219+
async fn exists(
220+
&mut self,
221+
self_: Resource<Bucket>,
222+
key: String,
223+
) -> Result<bool, wasi_keyvalue::store::Error> {
224+
let store = self.get_store_wasi(self_)?;
225+
store.exists(&key).await.map_err(to_wasi_err)
226+
}
227+
228+
async fn list_keys(
229+
&mut self,
230+
self_: Resource<Bucket>,
231+
cursor: Option<u64>,
232+
) -> Result<wasi_keyvalue::store::KeyResponse, wasi_keyvalue::store::Error> {
233+
if cursor.unwrap_or_default() != 0 {
234+
return Err(wasi_keyvalue::store::Error::Other(
235+
"list_keys: cursor not supported".to_owned(),
236+
));
237+
}
238+
239+
let store = self.get_store_wasi(self_)?;
240+
let keys = store.get_keys().await.map_err(to_wasi_err)?;
241+
Ok(wasi_keyvalue::store::KeyResponse { keys, cursor: None })
242+
}
243+
244+
async fn drop(&mut self, rep: Resource<Bucket>) -> anyhow::Result<()> {
245+
self.stores.remove(rep.rep());
246+
Ok(())
247+
}
248+
}
249+
144250
pub fn log_error(err: impl std::fmt::Debug) -> Error {
145251
tracing::warn!("key-value error: {err:?}");
146252
Error::Other(format!("{err:?}"))

crates/factor-key-value/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ impl Factor for KeyValueFactor {
4040
fn init<T: Send + 'static>(&mut self, mut ctx: InitContext<T, Self>) -> anyhow::Result<()> {
4141
ctx.link_bindings(spin_world::v1::key_value::add_to_linker)?;
4242
ctx.link_bindings(spin_world::v2::key_value::add_to_linker)?;
43+
ctx.link_bindings(spin_world::wasi::keyvalue::store::add_to_linker)?;
4344
Ok(())
4445
}
4546

crates/world/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ wasmtime::component::bindgen!({
2828
"fermyon:spin/[email protected]/error" => v2::sqlite::Error,
2929
"fermyon:spin/sqlite/error" => v1::sqlite::Error,
3030
"fermyon:spin/[email protected]/error" => v2::variables::Error,
31+
"wasi:keyvalue/store/error" => wasi::keyvalue::store::Error,
3132
},
3233
trappable_imports: true,
3334
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
spin_manifest_version = 2
2+
3+
[application]
4+
name = "key-value-wasi"
5+
authors = ["Fermyon Engineering <[email protected]>"]
6+
version = "0.1.0"
7+
8+
[[trigger.http]]
9+
route = "/"
10+
component = "test"
11+
12+
[component.test]
13+
source = "%{source=key-value-wasi}"
14+
key_value_stores = ["default"]

tests/test-components/components/Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[package]
2+
name = "key-value-wasi"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[lib]
7+
crate-type = ["cdylib"]
8+
9+
[dependencies]
10+
helper = { path = "../../helper" }
11+
wit-bindgen = "0.16.0"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Key Value
2+
3+
Tests the key/value interface.
4+
5+
## Expectations
6+
7+
This test component expects the following to be true:
8+
* It is given permission to open a connection to the "default" store.
9+
* It does not have permission to access a store named "forbidden".
10+
* It is empty
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use helper::{ensure_matches, ensure_ok};
2+
3+
use bindings::wasi::keyvalue::store::{Error, open, KeyResponse};
4+
5+
helper::define_component!(Component);
6+
7+
impl Component {
8+
fn main() -> Result<(), String> {
9+
10+
ensure_matches!(open("forbidden"), Err(Error::AccessDenied));
11+
12+
let store = ensure_ok!(open("default"));
13+
14+
// Ensure nothing set in `bar` key
15+
ensure_ok!(store.delete("bar"));
16+
ensure_matches!(store.exists("bar"), Ok(false));
17+
ensure_matches!(store.get("bar"), Ok(None));
18+
ensure_matches!(keys(&store.list_keys(None)), Ok(&[]));
19+
20+
// Set `bar` key
21+
ensure_ok!(store.set("bar", b"baz"));
22+
ensure_matches!(store.exists("bar"), Ok(true));
23+
ensure_matches!(store.get("bar"), Ok(Some(v)) if v == b"baz");
24+
ensure_matches!(keys(&store.list_keys(None)), Ok([bar]) if bar == "bar");
25+
ensure_matches!(keys(&store.list_keys(Some(0))), Ok([bar]) if bar == "bar");
26+
27+
// Override `bar` key
28+
ensure_ok!(store.set("bar", b"wow"));
29+
ensure_matches!(store.exists("bar"), Ok(true));
30+
ensure_matches!(store.get("bar"), Ok(Some(wow)) if wow == b"wow");
31+
ensure_matches!(keys(&store.list_keys(None)), Ok([bar]) if bar == "bar");
32+
33+
// Set another key
34+
ensure_ok!(store.set("qux", b"yay"));
35+
ensure_matches!(keys(&store.list_keys(None)), Ok(c) if c.len() == 2 && c.contains(&"bar".into()) && c.contains(&"qux".into()));
36+
37+
// Delete everything
38+
ensure_ok!(store.delete("bar"));
39+
ensure_ok!(store.delete("bar"));
40+
ensure_ok!(store.delete("qux"));
41+
ensure_matches!(store.exists("bar"), Ok(false));
42+
ensure_matches!(store.get("qux"), Ok(None));
43+
ensure_matches!(keys(&store.list_keys(None)), Ok(&[]));
44+
45+
Ok(())
46+
}
47+
}
48+
49+
fn keys<E>(res: &Result<KeyResponse, E>) -> Result<&[String], &E> {
50+
res.as_ref().map(|kr| kr.keys.as_slice())
51+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/// A keyvalue interface that provides atomic operations.
2+
///
3+
/// Atomic operations are single, indivisible operations. When a fault causes an atomic operation to
4+
/// fail, it will appear to the invoker of the atomic operation that the action either completed
5+
/// successfully or did nothing at all.
6+
///
7+
/// Please note that this interface is bare functions that take a reference to a bucket. This is to
8+
/// get around the current lack of a way to "extend" a resource with additional methods inside of
9+
/// wit. Future version of the interface will instead extend these methods on the base `bucket`
10+
/// resource.
11+
interface atomics {
12+
use store.{bucket, error};
13+
14+
/// Atomically increment the value associated with the key in the store by the given delta. It
15+
/// returns the new value.
16+
///
17+
/// If the key does not exist in the store, it creates a new key-value pair with the value set
18+
/// to the given delta.
19+
///
20+
/// If any other error occurs, it returns an `Err(error)`.
21+
increment: func(bucket: borrow<bucket>, key: string, delta: u64) -> result<u64, error>;
22+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/// A keyvalue interface that provides batch operations.
2+
///
3+
/// A batch operation is an operation that operates on multiple keys at once.
4+
///
5+
/// Batch operations are useful for reducing network round-trip time. For example, if you want to
6+
/// get the values associated with 100 keys, you can either do 100 get operations or you can do 1
7+
/// batch get operation. The batch operation is faster because it only needs to make 1 network call
8+
/// instead of 100.
9+
///
10+
/// A batch operation does not guarantee atomicity, meaning that if the batch operation fails, some
11+
/// of the keys may have been modified and some may not.
12+
///
13+
/// This interface does has the same consistency guarantees as the `store` interface, meaning that
14+
/// you should be able to "read your writes."
15+
///
16+
/// Please note that this interface is bare functions that take a reference to a bucket. This is to
17+
/// get around the current lack of a way to "extend" a resource with additional methods inside of
18+
/// wit. Future version of the interface will instead extend these methods on the base `bucket`
19+
/// resource.
20+
interface batch {
21+
use store.{bucket, error};
22+
23+
/// Get the key-value pairs associated with the keys in the store. It returns a list of
24+
/// key-value pairs.
25+
///
26+
/// If any of the keys do not exist in the store, it returns a `none` value for that pair in the
27+
/// list.
28+
///
29+
/// MAY show an out-of-date value if there are concurrent writes to the store.
30+
///
31+
/// If any other error occurs, it returns an `Err(error)`.
32+
get-many: func(bucket: borrow<bucket>, keys: list<string>) -> result<list<option<tuple<string, list<u8>>>>, error>;
33+
34+
/// Set the values associated with the keys in the store. If the key already exists in the
35+
/// store, it overwrites the value.
36+
///
37+
/// Note that the key-value pairs are not guaranteed to be set in the order they are provided.
38+
///
39+
/// If any of the keys do not exist in the store, it creates a new key-value pair.
40+
///
41+
/// If any other error occurs, it returns an `Err(error)`. When an error occurs, it does not
42+
/// rollback the key-value pairs that were already set. Thus, this batch operation does not
43+
/// guarantee atomicity, implying that some key-value pairs could be set while others might
44+
/// fail.
45+
///
46+
/// Other concurrent operations may also be able to see the partial results.
47+
set-many: func(bucket: borrow<bucket>, key-values: list<tuple<string, list<u8>>>) -> result<_, error>;
48+
49+
/// Delete the key-value pairs associated with the keys in the store.
50+
///
51+
/// Note that the key-value pairs are not guaranteed to be deleted in the order they are
52+
/// provided.
53+
///
54+
/// If any of the keys do not exist in the store, it skips the key.
55+
///
56+
/// If any other error occurs, it returns an `Err(error)`. When an error occurs, it does not
57+
/// rollback the key-value pairs that were already deleted. Thus, this batch operation does not
58+
/// guarantee atomicity, implying that some key-value pairs could be deleted while others might
59+
/// fail.
60+
///
61+
/// Other concurrent operations may also be able to see the partial results.
62+
delete-many: func(bucket: borrow<bucket>, keys: list<string>) -> result<_, error>;
63+
}

0 commit comments

Comments
 (0)