Skip to main content

wash_runtime/plugin/
wasi_keyvalue.rs

1//! # WASI KeyValue Memory Plugin
2//!
3//! This module implements an in-memory keyvalue plugin for the wasmCloud runtime,
4//! providing the `wasi:keyvalue@0.2.0-draft` interfaces for development and testing scenarios.
5
6use std::{
7    collections::{HashMap, HashSet},
8    sync::Arc,
9};
10
11const WASI_KEYVALUE_ID: &str = "wasi-keyvalue";
12use tokio::sync::RwLock;
13use wasmtime::component::Resource;
14
15use crate::{
16    engine::{
17        ctx::Ctx,
18        workload::{ResolvedWorkload, WorkloadComponent},
19    },
20    plugin::HostPlugin,
21    wit::{WitInterface, WitWorld},
22};
23
24mod bindings {
25    wasmtime::component::bindgen!({
26        world: "keyvalue",
27        trappable_imports: true,
28        async: true,
29        with: {
30            "wasi:keyvalue/store/bucket": crate::plugin::wasi_keyvalue::BucketHandle,
31        },
32    });
33}
34
35use bindings::wasi::keyvalue::store::{Error as StoreError, KeyResponse};
36
37/// In-memory bucket representation
38#[derive(Clone, Debug)]
39pub struct BucketData {
40    pub name: String,
41    pub data: HashMap<String, Vec<u8>>,
42    pub created_at: u64,
43}
44
45/// Resource representation for a bucket (key-value store)
46pub type BucketHandle = String;
47
48/// Memory-based keyvalue plugin
49#[derive(Clone, Default)]
50pub struct WasiKeyvalue {
51    /// Storage for all buckets, keyed by workload ID, then bucket name
52    storage: Arc<RwLock<HashMap<String, HashMap<String, BucketData>>>>,
53}
54
55impl WasiKeyvalue {
56    pub fn new() -> Self {
57        Self {
58            storage: Arc::new(RwLock::new(HashMap::new())),
59        }
60    }
61
62    fn get_timestamp() -> u64 {
63        std::time::SystemTime::now()
64            .duration_since(std::time::SystemTime::UNIX_EPOCH)
65            .unwrap_or_default()
66            .as_secs()
67    }
68}
69
70// Implementation for the store interface
71impl bindings::wasi::keyvalue::store::Host for Ctx {
72    async fn open(
73        &mut self,
74        identifier: String,
75    ) -> anyhow::Result<Result<Resource<BucketHandle>, StoreError>> {
76        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
77            return Ok(Err(StoreError::Other(
78                "keyvalue plugin not available".to_string(),
79            )));
80        };
81
82        let mut storage = plugin.storage.write().await;
83        let workload_storage = storage.entry(self.id.clone()).or_default();
84
85        // Create bucket if it doesn't exist
86        if !workload_storage.contains_key(&identifier) {
87            let bucket_data = BucketData {
88                name: identifier.clone(),
89                data: HashMap::new(),
90                created_at: WasiKeyvalue::get_timestamp(),
91            };
92            workload_storage.insert(identifier.clone(), bucket_data);
93        }
94
95        let resource = self.table.push(identifier)?;
96        Ok(Ok(resource))
97    }
98}
99
100// Resource host trait implementations for bucket
101impl bindings::wasi::keyvalue::store::HostBucket for Ctx {
102    async fn get(
103        &mut self,
104        bucket: Resource<BucketHandle>,
105        key: String,
106    ) -> anyhow::Result<Result<Option<Vec<u8>>, StoreError>> {
107        let bucket_name = self.table.get(&bucket)?;
108
109        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
110            return Ok(Err(StoreError::Other(
111                "keyvalue plugin not available".to_string(),
112            )));
113        };
114
115        let storage = plugin.storage.read().await;
116        let empty_map = HashMap::new();
117        let workload_storage = storage.get(&self.id).unwrap_or(&empty_map);
118
119        match workload_storage.get(bucket_name) {
120            Some(bucket_data) => {
121                let value = bucket_data.data.get(&key).cloned();
122                Ok(Ok(value))
123            }
124            None => Ok(Err(StoreError::Other(format!(
125                "bucket '{bucket_name}' does not exist"
126            )))),
127        }
128    }
129
130    async fn set(
131        &mut self,
132        bucket: Resource<BucketHandle>,
133        key: String,
134        value: Vec<u8>,
135    ) -> anyhow::Result<Result<(), StoreError>> {
136        let bucket_name = self.table.get(&bucket)?;
137
138        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
139            return Ok(Err(StoreError::Other(
140                "keyvalue plugin not available".to_string(),
141            )));
142        };
143
144        let mut storage = plugin.storage.write().await;
145        let workload_storage = storage.entry(self.id.clone()).or_default();
146
147        match workload_storage.get_mut(bucket_name) {
148            Some(bucket_data) => {
149                bucket_data.data.insert(key, value);
150                Ok(Ok(()))
151            }
152            None => Ok(Err(StoreError::Other(format!(
153                "bucket '{bucket_name}' does not exist"
154            )))),
155        }
156    }
157
158    async fn delete(
159        &mut self,
160        bucket: Resource<BucketHandle>,
161        key: String,
162    ) -> anyhow::Result<Result<(), StoreError>> {
163        let bucket_name = self.table.get(&bucket)?;
164
165        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
166            return Ok(Err(StoreError::Other(
167                "keyvalue plugin not available".to_string(),
168            )));
169        };
170
171        let mut storage = plugin.storage.write().await;
172        let workload_storage = storage.entry(self.id.clone()).or_default();
173
174        match workload_storage.get_mut(bucket_name) {
175            Some(bucket_data) => {
176                bucket_data.data.remove(&key);
177                Ok(Ok(()))
178            }
179            None => Ok(Err(StoreError::Other(format!(
180                "bucket '{bucket_name}' does not exist"
181            )))),
182        }
183    }
184
185    async fn exists(
186        &mut self,
187        bucket: Resource<BucketHandle>,
188        key: String,
189    ) -> anyhow::Result<Result<bool, StoreError>> {
190        let bucket_name = self.table.get(&bucket)?;
191
192        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
193            return Ok(Err(StoreError::Other(
194                "keyvalue plugin not available".to_string(),
195            )));
196        };
197
198        let storage = plugin.storage.read().await;
199        let empty_map = HashMap::new();
200        let workload_storage = storage.get(&self.id).unwrap_or(&empty_map);
201
202        match workload_storage.get(bucket_name) {
203            Some(bucket_data) => Ok(Ok(bucket_data.data.contains_key(&key))),
204            None => Ok(Err(StoreError::Other(format!(
205                "bucket '{bucket_name}' does not exist"
206            )))),
207        }
208    }
209
210    async fn list_keys(
211        &mut self,
212        bucket: Resource<BucketHandle>,
213        cursor: Option<u64>,
214    ) -> anyhow::Result<Result<KeyResponse, StoreError>> {
215        let bucket_name = self.table.get(&bucket)?;
216
217        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
218            return Ok(Err(StoreError::Other(
219                "keyvalue plugin not available".to_string(),
220            )));
221        };
222
223        let storage = plugin.storage.read().await;
224        let empty_map = HashMap::new();
225        let workload_storage = storage.get(&self.id).unwrap_or(&empty_map);
226
227        match workload_storage.get(bucket_name) {
228            Some(bucket_data) => {
229                let mut keys: Vec<String> = bucket_data.data.keys().cloned().collect();
230                keys.sort(); // Ensure consistent ordering
231
232                // Simple cursor-based pagination - cursor is the index from previous page
233                let start_index = cursor.unwrap_or(0) as usize;
234
235                // Return up to 100 keys per page
236                const PAGE_SIZE: usize = 100;
237                let end_index = std::cmp::min(start_index + PAGE_SIZE, keys.len());
238                let page_keys = keys[start_index..end_index].to_vec();
239
240                // Set next cursor if there are more keys
241                let next_cursor = if end_index < keys.len() {
242                    Some(end_index as u64)
243                } else {
244                    None
245                };
246
247                Ok(Ok(KeyResponse {
248                    keys: page_keys,
249                    cursor: next_cursor,
250                }))
251            }
252            None => Ok(Err(StoreError::Other(format!(
253                "bucket '{bucket_name}' does not exist"
254            )))),
255        }
256    }
257
258    async fn drop(&mut self, rep: Resource<BucketHandle>) -> anyhow::Result<()> {
259        tracing::debug!(
260            workload_id = self.id,
261            resource_id = ?rep,
262            "Dropping bucket resource"
263        );
264        self.table.delete(rep)?;
265        Ok(())
266    }
267}
268
269// Implementation for the atomics interface
270impl bindings::wasi::keyvalue::atomics::Host for Ctx {
271    async fn increment(
272        &mut self,
273        bucket: Resource<BucketHandle>,
274        key: String,
275        delta: u64,
276    ) -> anyhow::Result<Result<u64, StoreError>> {
277        let bucket_name = self.table.get(&bucket)?;
278
279        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
280            return Ok(Err(StoreError::Other(
281                "keyvalue plugin not available".to_string(),
282            )));
283        };
284
285        let mut storage = plugin.storage.write().await;
286        let workload_storage = storage.entry(self.id.clone()).or_default();
287
288        match workload_storage.get_mut(bucket_name) {
289            Some(bucket_data) => {
290                // Get current value, treating missing key as 0
291                let current_bytes = bucket_data.data.get(&key);
292                let current_value = if let Some(bytes) = current_bytes {
293                    // Try to parse as u64 from 8-byte array
294                    if bytes.len() == 8 {
295                        u64::from_le_bytes(bytes.clone().try_into().unwrap_or([0; 8]))
296                    } else {
297                        // Try to parse as string representation
298                        String::from_utf8_lossy(bytes).parse::<u64>().unwrap_or(0)
299                    }
300                } else {
301                    0
302                };
303
304                let new_value = current_value.saturating_add(delta);
305
306                // Store as 8-byte little-endian representation
307                bucket_data
308                    .data
309                    .insert(key, new_value.to_le_bytes().to_vec());
310
311                Ok(Ok(new_value))
312            }
313            None => Ok(Err(StoreError::Other(format!(
314                "bucket '{bucket_name}' does not exist"
315            )))),
316        }
317    }
318}
319
320// Implementation for the batch interface
321impl bindings::wasi::keyvalue::batch::Host for Ctx {
322    async fn get_many(
323        &mut self,
324        bucket: Resource<BucketHandle>,
325        keys: Vec<String>,
326    ) -> anyhow::Result<Result<Vec<Option<(String, Vec<u8>)>>, StoreError>> {
327        let bucket_name = self.table.get(&bucket)?;
328
329        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
330            return Ok(Err(StoreError::Other(
331                "keyvalue plugin not available".to_string(),
332            )));
333        };
334
335        let storage = plugin.storage.read().await;
336        let empty_map = HashMap::new();
337        let workload_storage = storage.get(&self.id).unwrap_or(&empty_map);
338
339        match workload_storage.get(bucket_name) {
340            Some(bucket_data) => {
341                let results: Vec<Option<(String, Vec<u8>)>> = keys
342                    .into_iter()
343                    .map(|key| {
344                        bucket_data
345                            .data
346                            .get(&key)
347                            .cloned()
348                            .map(|value| (key, value))
349                    })
350                    .collect();
351                Ok(Ok(results))
352            }
353            None => Ok(Err(StoreError::Other(format!(
354                "bucket '{bucket_name}' does not exist"
355            )))),
356        }
357    }
358
359    async fn set_many(
360        &mut self,
361        bucket: Resource<BucketHandle>,
362        key_values: Vec<(String, Vec<u8>)>,
363    ) -> anyhow::Result<Result<(), StoreError>> {
364        let bucket_name = self.table.get(&bucket)?;
365
366        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
367            return Ok(Err(StoreError::Other(
368                "keyvalue plugin not available".to_string(),
369            )));
370        };
371
372        let mut storage = plugin.storage.write().await;
373        let workload_storage = storage.entry(self.id.clone()).or_default();
374
375        match workload_storage.get_mut(bucket_name) {
376            Some(bucket_data) => {
377                for (key, value) in key_values {
378                    bucket_data.data.insert(key, value);
379                }
380                Ok(Ok(()))
381            }
382            None => Ok(Err(StoreError::Other(format!(
383                "bucket '{bucket_name}' does not exist"
384            )))),
385        }
386    }
387
388    async fn delete_many(
389        &mut self,
390        bucket: Resource<BucketHandle>,
391        keys: Vec<String>,
392    ) -> anyhow::Result<Result<(), StoreError>> {
393        let bucket_name = self.table.get(&bucket)?;
394
395        let Some(plugin) = self.get_plugin::<WasiKeyvalue>(WASI_KEYVALUE_ID) else {
396            return Ok(Err(StoreError::Other(
397                "keyvalue plugin not available".to_string(),
398            )));
399        };
400
401        let mut storage = plugin.storage.write().await;
402        let workload_storage = storage.entry(self.id.clone()).or_default();
403
404        match workload_storage.get_mut(bucket_name) {
405            Some(bucket_data) => {
406                for key in keys {
407                    bucket_data.data.remove(&key);
408                }
409                Ok(Ok(()))
410            }
411            None => Ok(Err(StoreError::Other(format!(
412                "bucket '{bucket_name}' does not exist"
413            )))),
414        }
415    }
416}
417
418#[async_trait::async_trait]
419impl HostPlugin for WasiKeyvalue {
420    fn id(&self) -> &'static str {
421        WASI_KEYVALUE_ID
422    }
423
424    fn world(&self) -> WitWorld {
425        WitWorld {
426            imports: HashSet::from([WitInterface::from(
427                "wasi:keyvalue/store,atomics,batch@0.2.0-draft",
428            )]),
429            ..Default::default()
430        }
431    }
432
433    async fn on_component_bind(
434        &self,
435        component: &mut WorkloadComponent,
436        interfaces: std::collections::HashSet<crate::wit::WitInterface>,
437    ) -> anyhow::Result<()> {
438        // Check if any of the interfaces are wasi:keyvalue related
439        let has_keyvalue = interfaces
440            .iter()
441            .any(|i| i.namespace == "wasi" && i.package == "keyvalue");
442
443        if !has_keyvalue {
444            tracing::warn!(
445                "WasiKeyvalue plugin requested for non-wasi:keyvalue interface(s): {:?}",
446                interfaces
447            );
448            return Ok(());
449        }
450
451        tracing::debug!(
452            workload_id = component.id(),
453            "Adding keyvalue interfaces to linker for workload"
454        );
455        let linker = component.linker();
456
457        bindings::wasi::keyvalue::store::add_to_linker(linker, |ctx| ctx)?;
458        bindings::wasi::keyvalue::atomics::add_to_linker(linker, |ctx| ctx)?;
459        bindings::wasi::keyvalue::batch::add_to_linker(linker, |ctx| ctx)?;
460
461        let id = component.id();
462        tracing::debug!(
463            workload_id = id,
464            "Successfully added keyvalue interfaces to linker for workload"
465        );
466
467        // Initialize storage for this workload
468        let mut storage = self.storage.write().await;
469        storage.insert(id.to_string(), HashMap::new());
470
471        tracing::debug!("WasiKeyvalue plugin bound to workload '{id}'");
472
473        Ok(())
474    }
475
476    async fn on_workload_unbind(
477        &self,
478        workload_handle: &ResolvedWorkload,
479        _interfaces: std::collections::HashSet<crate::wit::WitInterface>,
480    ) -> anyhow::Result<()> {
481        let id = workload_handle.id();
482        // Clean up storage for this workload
483        let mut storage = self.storage.write().await;
484        storage.remove(id);
485
486        tracing::debug!("WasiKeyvalue plugin unbound from workload '{id}'");
487
488        Ok(())
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_wasi_keyvalue_creation() {
498        let keyvalue = WasiKeyvalue::new();
499        assert!(keyvalue.storage.try_read().is_ok());
500    }
501
502    #[test]
503    fn test_get_timestamp() {
504        let timestamp = WasiKeyvalue::get_timestamp();
505        assert!(timestamp > 0);
506    }
507
508    #[test]
509    fn test_bucket_data_creation() {
510        let bucket = BucketData {
511            name: "test-bucket".to_string(),
512            data: HashMap::new(),
513            created_at: WasiKeyvalue::get_timestamp(),
514        };
515
516        assert_eq!(bucket.name, "test-bucket");
517        assert!(bucket.data.is_empty());
518        assert!(bucket.created_at > 0);
519    }
520
521    #[tokio::test]
522    async fn test_storage_operations() {
523        let keyvalue = WasiKeyvalue::new();
524
525        // Test write access
526        {
527            let mut storage = keyvalue.storage.write().await;
528            storage.insert("workload1".to_string(), HashMap::new());
529        }
530
531        // Test read access
532        {
533            let storage = keyvalue.storage.read().await;
534            assert!(storage.contains_key("workload1"));
535        }
536    }
537
538    #[test]
539    fn test_batch_operations_data_structures() {
540        // Test that we can create the data structures for batch operations
541        let key_values = [
542            ("key1".to_string(), b"value1".to_vec()),
543            ("key2".to_string(), b"value2".to_vec()),
544        ];
545        assert_eq!(key_values.len(), 2);
546
547        let keys = ["key1".to_string(), "key2".to_string()];
548        assert_eq!(keys.len(), 2);
549
550        let results: Vec<Option<(String, Vec<u8>)>> = vec![
551            Some(("key1".to_string(), b"value1".to_vec())),
552            None, // key not found
553        ];
554        assert_eq!(results.len(), 2);
555        assert!(results[0].is_some());
556        assert!(results[1].is_none());
557    }
558}