feat(runtime): host service-scoped KV capability
Introduce an injectable, deadline-aware KvBackend boundary and link only the exact wasmeld:kv/store@0.1.0 import requested by a Component. Namespace entries by service ID so revisions share state while services remain isolated, reject registrations without a backend, and enforce 256-byte keys and 64 KiB values. Cover capability discovery, cross-revision sharing, service isolation, missing backends, and resource limits with Component integration tests.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
//! `wasmeld:kv/store@0.1.0` host implementation and backend boundary.
|
||||
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
|
||||
use thiserror::Error;
|
||||
use wasmtime::component::Linker;
|
||||
|
||||
use crate::{RuntimeError, runtime::HostState};
|
||||
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../../wit/kv",
|
||||
world: "kv-host",
|
||||
});
|
||||
|
||||
pub(crate) const INTERFACE: &str = "wasmeld:kv/store@0.1.0";
|
||||
pub(crate) const PACKAGE: &str = "wasmeld:kv";
|
||||
pub(crate) const NAME: &str = "store";
|
||||
pub(crate) const VERSION: &str = "0.1.0";
|
||||
|
||||
/// Maximum UTF-8 byte length accepted for one KV key.
|
||||
pub const KV_MAX_KEY_BYTES: usize = 256;
|
||||
/// Maximum byte length accepted for one KV value.
|
||||
pub const KV_MAX_VALUE_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Synchronous storage boundary used by the Wasmtime host implementation.
|
||||
///
|
||||
/// Implementations must bound their own blocking time because calls execute on
|
||||
/// the Component Actor thread.
|
||||
pub trait KvBackend: fmt::Debug + Send + Sync {
|
||||
/// Returns the value stored under a service-scoped key.
|
||||
fn get(
|
||||
&self,
|
||||
service_id: &str,
|
||||
key: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<Vec<u8>>, KvBackendError>;
|
||||
/// Creates or replaces a service-scoped value.
|
||||
fn set(
|
||||
&self,
|
||||
service_id: &str,
|
||||
key: &str,
|
||||
value: &[u8],
|
||||
timeout: Duration,
|
||||
) -> Result<(), KvBackendError>;
|
||||
/// Removes a service-scoped value if it exists.
|
||||
fn delete(&self, service_id: &str, key: &str, timeout: Duration) -> Result<(), KvBackendError>;
|
||||
}
|
||||
|
||||
/// Storage failures returned by a [`KvBackend`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum KvBackendError {
|
||||
#[error("KV backend is busy")]
|
||||
Busy,
|
||||
#[error("KV backend timed out")]
|
||||
Timeout,
|
||||
#[error("KV backend operation failed: {0}")]
|
||||
Operation(String),
|
||||
}
|
||||
|
||||
pub(crate) struct KvState {
|
||||
service_id: String,
|
||||
backend: Arc<dyn KvBackend>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl KvState {
|
||||
pub(crate) fn new(service_id: &str, backend: Arc<dyn KvBackend>, timeout: Duration) -> Self {
|
||||
Self {
|
||||
service_id: service_id.to_owned(),
|
||||
backend,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, wasmeld::kv::store::KvError> {
|
||||
validate_key(key)?;
|
||||
self.backend
|
||||
.get(&self.service_id, key, self.timeout)
|
||||
.map_err(operation_failed)
|
||||
.and_then(|value| match value {
|
||||
Some(value) if value.len() > KV_MAX_VALUE_BYTES => {
|
||||
Err(wasmeld::kv::store::KvError::ValueTooLarge(
|
||||
u64::try_from(value.len()).unwrap_or(u64::MAX),
|
||||
))
|
||||
}
|
||||
value => Ok(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: &[u8]) -> Result<(), wasmeld::kv::store::KvError> {
|
||||
validate_key(key)?;
|
||||
if value.len() > KV_MAX_VALUE_BYTES {
|
||||
return Err(wasmeld::kv::store::KvError::ValueTooLarge(
|
||||
u64::try_from(value.len()).unwrap_or(u64::MAX),
|
||||
));
|
||||
}
|
||||
self.backend
|
||||
.set(&self.service_id, key, value, self.timeout)
|
||||
.map_err(operation_failed)
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<(), wasmeld::kv::store::KvError> {
|
||||
validate_key(key)?;
|
||||
self.backend
|
||||
.delete(&self.service_id, key, self.timeout)
|
||||
.map_err(operation_failed)
|
||||
}
|
||||
}
|
||||
|
||||
impl wasmeld::kv::store::Host for HostState {
|
||||
fn get(&mut self, key: String) -> Result<Option<Vec<u8>>, wasmeld::kv::store::KvError> {
|
||||
self.capabilities.kv().get(&key)
|
||||
}
|
||||
|
||||
fn set(&mut self, key: String, value: Vec<u8>) -> Result<(), wasmeld::kv::store::KvError> {
|
||||
self.capabilities.kv().set(&key, &value)
|
||||
}
|
||||
|
||||
fn delete(&mut self, key: String) -> Result<(), wasmeld::kv::store::KvError> {
|
||||
self.capabilities.kv().delete(&key)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_to_linker(linker: &mut Linker<HostState>) -> Result<(), RuntimeError> {
|
||||
wasmeld::kv::store::add_to_linker::<HostState, HostState>(linker, |state| state)
|
||||
.map_err(|error| RuntimeError::ActorInitialization(error.to_string()))
|
||||
}
|
||||
|
||||
fn validate_key(key: &str) -> Result<(), wasmeld::kv::store::KvError> {
|
||||
if key.is_empty() {
|
||||
return Err(wasmeld::kv::store::KvError::InvalidKey(
|
||||
"key must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
if key.len() > KV_MAX_KEY_BYTES {
|
||||
return Err(wasmeld::kv::store::KvError::InvalidKey(format!(
|
||||
"key exceeds the {KV_MAX_KEY_BYTES}-byte limit"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn operation_failed(error: KvBackendError) -> wasmeld::kv::store::KvError {
|
||||
wasmeld::kv::store::KvError::OperationFailed(error.to_string())
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
//! Versioned host capabilities available to WebAssembly Components.
|
||||
|
||||
mod clock;
|
||||
mod kv;
|
||||
mod registry;
|
||||
|
||||
pub use kv::{KV_MAX_KEY_BYTES, KV_MAX_VALUE_BYTES, KvBackend, KvBackendError};
|
||||
pub use registry::CapabilityDescriptor;
|
||||
pub(crate) use registry::{Capability, CapabilityRegistry};
|
||||
|
||||
/// Per-Actor state owned by enabled host capabilities.
|
||||
pub(crate) struct HostCapabilities {
|
||||
clock: Option<clock::ClockState>,
|
||||
kv: Option<kv::KvState>,
|
||||
}
|
||||
|
||||
impl HostCapabilities {
|
||||
pub(crate) fn new(capabilities: &[Capability]) -> Self {
|
||||
pub(crate) fn new(
|
||||
capabilities: &[Capability],
|
||||
service_id: &str,
|
||||
kv_backend: Option<std::sync::Arc<dyn KvBackend>>,
|
||||
deadline: std::time::Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
clock: capabilities
|
||||
.contains(&Capability::MonotonicClock)
|
||||
.then(clock::ClockState::new),
|
||||
kv: capabilities.contains(&Capability::KvStore).then(|| {
|
||||
kv::KvState::new(
|
||||
service_id,
|
||||
kv_backend.expect("KV Components are rejected when no backend is configured"),
|
||||
deadline,
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +41,10 @@ impl HostCapabilities {
|
||||
.expect("the Clock interface is linked only when its state is enabled")
|
||||
.now()
|
||||
}
|
||||
|
||||
fn kv(&self) -> &kv::KvState {
|
||||
self.kv
|
||||
.as_ref()
|
||||
.expect("the KV interface is linked only when its state is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use wasmtime::component::Linker;
|
||||
|
||||
use super::clock;
|
||||
use super::{clock, kv};
|
||||
use crate::{RuntimeError, runtime::HostState};
|
||||
|
||||
/// Stable description of one versioned WIT interface used by a Component.
|
||||
@@ -53,6 +53,7 @@ impl CapabilityDescriptor {
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(crate) enum Capability {
|
||||
MonotonicClock,
|
||||
KvStore,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
@@ -64,12 +65,16 @@ impl Capability {
|
||||
clock::NAME,
|
||||
clock::VERSION,
|
||||
),
|
||||
Self::KvStore => {
|
||||
CapabilityDescriptor::new(kv::INTERFACE, kv::PACKAGE, kv::NAME, kv::VERSION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_to_linker(self, linker: &mut Linker<HostState>) -> Result<(), RuntimeError> {
|
||||
match self {
|
||||
Self::MonotonicClock => clock::add_to_linker(linker),
|
||||
Self::KvStore => kv::add_to_linker(linker),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +87,7 @@ impl CapabilityRegistry {
|
||||
pub(crate) fn resolve(interface: &str) -> Option<Capability> {
|
||||
match interface {
|
||||
clock::INTERFACE => Some(Capability::MonotonicClock),
|
||||
kv::INTERFACE => Some(Capability::KvStore),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -116,5 +122,10 @@ mod tests {
|
||||
CapabilityRegistry::resolve("wasmeld:clock/other@0.1.0"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
CapabilityRegistry::resolve("wasmeld:kv/store@0.1.0"),
|
||||
Some(Capability::KvStore)
|
||||
);
|
||||
assert_eq!(CapabilityRegistry::resolve("wasmeld:kv/store@0.2.0"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ pub enum RuntimeError {
|
||||
#[error("component imports unsupported capability {0}")]
|
||||
UnsupportedImport(String),
|
||||
|
||||
#[error("component requires unavailable capability {0}")]
|
||||
CapabilityUnavailable(String),
|
||||
|
||||
#[error("runtime creation failed: {0}")]
|
||||
RuntimeCreation(#[source] wasmtime::Error),
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ mod error;
|
||||
mod manifest;
|
||||
mod runtime;
|
||||
|
||||
pub use capability::CapabilityDescriptor;
|
||||
pub use capability::{
|
||||
CapabilityDescriptor, KV_MAX_KEY_BYTES, KV_MAX_VALUE_BYTES, KvBackend, KvBackendError,
|
||||
};
|
||||
pub use error::RuntimeError;
|
||||
pub use manifest::{ResourceLimits, ServiceKey, ServiceManifest};
|
||||
pub use runtime::{ActorHandle, Runtime, RuntimeConfig, RuntimeStats};
|
||||
|
||||
@@ -30,7 +30,7 @@ use wasmtime_wasi::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CapabilityDescriptor, RuntimeError, ServiceKey, ServiceManifest,
|
||||
CapabilityDescriptor, KvBackend, RuntimeError, ServiceKey, ServiceManifest,
|
||||
bindings::{ServiceComponent, ServiceError},
|
||||
capability::{Capability, CapabilityRegistry, HostCapabilities},
|
||||
manifest::ResourceLimits,
|
||||
@@ -61,6 +61,8 @@ pub struct RuntimeConfig {
|
||||
pub epoch_tick: Duration,
|
||||
/// Maximum native stack reservation for WebAssembly execution.
|
||||
pub max_wasm_stack: usize,
|
||||
/// Service-scoped storage used by Components importing the KV capability.
|
||||
pub kv_backend: Option<Arc<dyn KvBackend>>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeConfig {
|
||||
@@ -68,6 +70,7 @@ impl Default for RuntimeConfig {
|
||||
Self {
|
||||
epoch_tick: DEFAULT_EPOCH_TICK,
|
||||
max_wasm_stack: DEFAULT_MAX_WASM_STACK,
|
||||
kv_backend: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +120,7 @@ struct RuntimeInner {
|
||||
actors: Mutex<HashMap<ServiceKey, ActorHandle>>,
|
||||
lifecycle: Mutex<()>,
|
||||
ticker: Arc<EpochTicker>,
|
||||
kv_backend: Option<Arc<dyn KvBackend>>,
|
||||
}
|
||||
|
||||
struct EpochTicker {
|
||||
@@ -189,6 +193,7 @@ impl Runtime {
|
||||
actors: Mutex::new(HashMap::new()),
|
||||
lifecycle: Mutex::new(()),
|
||||
ticker,
|
||||
kv_backend: config.kv_backend,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -207,6 +212,11 @@ impl Runtime {
|
||||
let component = Component::new(&self.inner.engine, component_bytes.as_ref())
|
||||
.map_err(RuntimeError::ComponentCompilation)?;
|
||||
let capabilities = validate_component_imports(&component, &self.inner.engine)?;
|
||||
if capabilities.contains(&Capability::KvStore) && self.inner.kv_backend.is_none() {
|
||||
return Err(RuntimeError::CapabilityUnavailable(
|
||||
Capability::KvStore.descriptor().interface().to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut services = self
|
||||
.inner
|
||||
@@ -298,6 +308,7 @@ impl Runtime {
|
||||
key.clone(),
|
||||
service,
|
||||
init_config,
|
||||
self.inner.kv_backend.clone(),
|
||||
)?;
|
||||
|
||||
self.inner
|
||||
@@ -580,7 +591,12 @@ pub(crate) struct HostState {
|
||||
}
|
||||
|
||||
impl HostState {
|
||||
fn new(limits: &ResourceLimits, capabilities: &[Capability]) -> Self {
|
||||
fn new(
|
||||
limits: &ResourceLimits,
|
||||
capabilities: &[Capability],
|
||||
service_id: &str,
|
||||
kv_backend: Option<Arc<dyn KvBackend>>,
|
||||
) -> Self {
|
||||
let store_limits = StoreLimitsBuilder::new()
|
||||
.memory_size(limits.memory_bytes)
|
||||
.table_elements(ACTOR_TABLE_ELEMENT_LIMIT)
|
||||
@@ -600,7 +616,12 @@ impl HostState {
|
||||
store_limits,
|
||||
wasi: wasi.build(),
|
||||
wasi_table: ResourceTable::new(),
|
||||
capabilities: HostCapabilities::new(capabilities),
|
||||
capabilities: HostCapabilities::new(
|
||||
capabilities,
|
||||
service_id,
|
||||
kv_backend,
|
||||
limits.deadline(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -639,6 +660,7 @@ impl ActorWorker {
|
||||
key: ServiceKey,
|
||||
service: RegisteredService,
|
||||
init_config: Vec<u8>,
|
||||
kv_backend: Option<Arc<dyn KvBackend>>,
|
||||
) -> Result<Self, RuntimeError> {
|
||||
let limits = Arc::new(service.manifest.limits.clone());
|
||||
if init_config.len() > limits.max_input_bytes {
|
||||
@@ -648,7 +670,10 @@ impl ActorWorker {
|
||||
});
|
||||
}
|
||||
|
||||
let mut store = Store::new(&engine, HostState::new(&limits, &service.capabilities));
|
||||
let mut store = Store::new(
|
||||
&engine,
|
||||
HostState::new(&limits, &service.capabilities, key.id(), kv_backend),
|
||||
);
|
||||
store.limiter(|state| &mut state.store_limits);
|
||||
|
||||
let mut linker = Linker::new(&engine);
|
||||
@@ -797,6 +822,7 @@ fn spawn_actor(
|
||||
key: ServiceKey,
|
||||
service: RegisteredService,
|
||||
init_config: Vec<u8>,
|
||||
kv_backend: Option<Arc<dyn KvBackend>>,
|
||||
) -> Result<ActorHandle, RuntimeError> {
|
||||
let limits = Arc::new(service.manifest.limits.clone());
|
||||
let (sender, receiver) = mpsc::sync_channel(limits.mailbox_capacity);
|
||||
@@ -811,7 +837,7 @@ fn spawn_actor(
|
||||
.spawn(move || {
|
||||
let _ticker = worker_ticker;
|
||||
|
||||
match ActorWorker::create(engine, epoch_tick, key, service, init_config) {
|
||||
match ActorWorker::create(engine, epoch_tick, key, service, init_config, kv_backend) {
|
||||
Ok(mut worker) => {
|
||||
worker_status.mark_ready();
|
||||
let _ = ready_sender.send(Ok(()));
|
||||
|
||||
Reference in New Issue
Block a user