use crate::error::LaserError;
use crate::kv::{
AGDX_KV_CAS_CODE, AGDX_KV_CAS_FENCED_CODE, AGDX_KV_COPY_CODE, AGDX_KV_DELETE_CODE,
AGDX_KV_DELETE_MANY_CODE, AGDX_KV_EXISTS_CODE, AGDX_KV_EXPIRE_CODE, AGDX_KV_GET_CODE,
AGDX_KV_LEASE_RENEW_CODE, AGDX_KV_MOVE_CODE, AGDX_KV_NAMESPACES_CODE, AGDX_KV_PATCH_CODE,
AGDX_KV_RELEASE_CODE, AGDX_KV_SCAN_CODE, AGDX_KV_SET_CODE, CasExpect, DEFAULT_SCAN_LIMIT,
KV_LEASE_OP_VERSION, KV_OP_VERSION, KvCasFenced, KvEntry, KvError, KvGet, KvLease,
KvLeaseRenew, KvMetadata, KvNamespaceInfo, KvPage, KvRelease, MAX_KEY_BYTES, MAX_VALUE_BYTES,
};
use crate::laser::Laser;
use crate::stream::{Codec, Decoder};
use crate::types::ConversationId;
use laser_wire::framing::encode_named;
use laser_wire::kv::{
KvCas, KvCopy, KvDelete, KvDeleteMany, KvExists, KvExpire, KvNamespaces, KvOutcome, KvPatch,
KvReply, KvScan, KvSet,
};
use laser_wire::mutation::MutationPosition;
use laser_wire::validate::Validate;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Lease {
pub token: u64,
pub granted_ttl: Duration,
pub position: MutationPosition,
}
impl Laser {
pub fn kv(&self, namespace: impl Into<String>) -> Kv {
let namespace = self.resource_name(&namespace.into());
Kv {
laser: self.clone(),
namespace,
session: None,
}
}
pub async fn kv_namespaces(&self) -> Result<Vec<KvNamespaceInfo>, LaserError> {
let request = KvNamespaces { v: KV_OP_VERSION };
match self
.execute_kv(None, AGDX_KV_NAMESPACES_CODE, &request)
.await?
{
KvOutcome::Namespaces(namespaces) => Ok(self.local_namespaces(namespaces)),
_ => Err(LaserError::Protocol(
"kv namespaces: unexpected outcome".to_owned(),
)),
}
}
fn local_namespaces(&self, namespaces: Vec<KvNamespaceInfo>) -> Vec<KvNamespaceInfo> {
namespaces
.into_iter()
.filter_map(|mut info| {
let local = self.local_resource_name(&info.namespace)?.to_owned();
info.namespace = local;
Some(info)
})
.collect()
}
pub(crate) async fn execute_kv(
&self,
namespace: Option<&str>,
code: u32,
request: &impl Serialize,
) -> Result<KvOutcome, LaserError> {
if let Some(namespace) = namespace {
laser_wire::kv::validate_namespace(namespace)?;
}
let capabilities = self.capabilities().await;
if !capabilities.kv.available {
return Err(LaserError::unsupported(
"kv",
"the key-value surface is not served by this deployment",
));
}
if let Some(versions) = capabilities.versions
&& versions.kv != KV_OP_VERSION
{
return Err(KvError::Version {
expected: versions.kv,
got: KV_OP_VERSION,
}
.into());
}
let payload = encode_named(request)
.map_err(|error| LaserError::Codec(format!("encode request: {error}")))?;
let payload = self.send_raw_with_response(code, payload).await?;
match crate::error::decode_managed_reply::<KvReply>(&payload)? {
KvReply::Ok(outcome) => Ok(outcome),
KvReply::Err(error) => Err(error.into()),
_ => Err(LaserError::Protocol("kv: unknown reply variant".to_owned())),
}
}
}
pub struct Kv {
laser: Laser,
namespace: String,
session: Option<laser_wire::agent::SessionRef>,
}
impl Kv {
#[must_use]
pub fn in_session(mut self, session: laser_wire::agent::SessionRef) -> Self {
self.session = Some(session);
self
}
pub fn session(&self) -> Option<&laser_wire::agent::SessionRef> {
self.session.as_ref()
}
pub fn namespace(&self) -> &str {
self.laser
.local_resource_name(&self.namespace)
.unwrap_or(&self.namespace)
}
pub fn resource_namespace(&self) -> &str {
&self.namespace
}
pub async fn get(&self, key: impl AsRef<[u8]>) -> Result<Option<Vec<u8>>, LaserError> {
Ok(self.get_entry(key).await?.map(|entry| entry.value))
}
pub async fn get_entry(&self, key: impl AsRef<[u8]>) -> Result<Option<KvEntry>, LaserError> {
let key = validated_key(key.as_ref())?;
let request = KvGet {
v: KV_OP_VERSION,
namespace: self.namespace.clone(),
key,
if_none_match: None,
min_position: None,
};
match self
.laser
.execute_kv(Some(&self.namespace), AGDX_KV_GET_CODE, &request)
.await?
{
KvOutcome::Value(entry) => Ok(entry),
other => Err(unexpected("get", &other)),
}
}
pub async fn get_entry_at_least(
&self,
key: impl AsRef<[u8]>,
min_position: MutationPosition,
) -> Result<Option<KvEntry>, LaserError> {
let key = validated_key(key.as_ref())?;
self.require_fenced_leases("get_entry_at_least").await?;
let request = KvGet {
v: KV_OP_VERSION,
namespace: self.namespace.clone(),
key,
if_none_match: None,
min_position: Some(min_position),
};
match self
.laser
.execute_kv(Some(&self.namespace), AGDX_KV_GET_CODE, &request)
.await?
{
KvOutcome::Value(entry) => Ok(entry),
other => Err(unexpected("get", &other)),
}
}
pub async fn get_typed<T: DeserializeOwned>(
&self,
key: impl AsRef<[u8]>,
) -> Result<Option<T>, LaserError> {
self.get_as::<crate::stream::Json, T>(key).await
}
pub async fn get_as<C, T>(&self, key: impl AsRef<[u8]>) -> Result<Option<T>, LaserError>
where
C: Decoder<T>,
{
match self.get(key).await? {
Some(payload) => C::decode(&payload).map(Some).map_err(LaserError::from),
None => Ok(None),
}
}
pub fn set(&self, key: impl AsRef<[u8]>) -> KvSetRequest {
KvSetRequest {
laser: self.laser.clone(),
namespace: self.namespace.clone(),
session: self.session.clone(),
key: key.as_ref().to_vec(),
value: Vec::new(),
expires_at_micros: None,
expect: None,
}
}
pub fn cas_fenced(
&self,
key: impl AsRef<[u8]>,
fence_namespace: impl Into<String>,
fence_key: impl AsRef<[u8]>,
fence_token: u64,
) -> KvCasFencedRequest {
KvCasFencedRequest {
laser: self.laser.clone(),
namespace: self.namespace.clone(),
key: key.as_ref().to_vec(),
value: Vec::new(),
expires_at_micros: None,
expect: None,
fence_namespace: self.laser.resource_name(&fence_namespace.into()),
fence_key: fence_key.as_ref().to_vec(),
fence_token,
}
}
pub async fn delete(&self, key: impl AsRef<[u8]>) -> Result<bool, LaserError> {
let key = validated_key(key.as_ref())?;
let request = KvDelete {
v: KV_OP_VERSION,
namespace: self.namespace.clone(),
session: self.session.clone(),
key,
if_match: None,
};
match self
.laser
.execute_kv(Some(&self.namespace), AGDX_KV_DELETE_CODE, &request)
.await?
{
KvOutcome::Deleted(existed) => Ok(existed),
other => Err(unexpected("delete", &other)),
}
}
pub async fn exists(&self, key: impl AsRef<[u8]>) -> Result<Option<KvMetadata>, LaserError> {
let key = validated_key(key.as_ref())?;
let request = KvExists {
v: KV_OP_VERSION,
namespace: self.namespace.clone(),
key,
};
match self
.laser
.execute_kv(Some(&self.namespace), AGDX_KV_EXISTS_CODE, &request)
.await?
{
KvOutcome::Metadata(metadata) => Ok(metadata),
other => Err(unexpected("exists", &other)),
}
}
pub async fn expire(
&self,
key: impl AsRef<[u8]>,
ttl: Option<Duration>,
) -> Result<u64, LaserError> {
let expires_at_micros = ttl.map(|ttl| now_micros().saturating_add(duration_micros(ttl)));
self.expire_at(key, expires_at_micros).await
}
pub async fn expire_at(
&self,
key: impl AsRef<[u8]>,
expires_at_micros: Option<u64>,
) -> Result<u64, LaserError> {
let key = validated_key(key.as_ref())?;
let request = KvExpire {
v: KV_OP_VERSION,
namespace: self.namespace.clone(),
key,
expires_at_micros,
};
match self
.laser
.execute_kv(Some(&self.namespace), AGDX_KV_EXPIRE_CODE, &request)
.await?
{
KvOutcome::Versioned { version } => Ok(version),
other => Err(unexpected("expire", &other)),
}
}
pub async fn patch(
&self,
key: impl AsRef<[u8]>,
patch: impl Into<Vec<u8>>,
) -> Result<u64, LaserError> {
let key = validated_key(key.as_ref())?;
let request = KvPatch {
v: KV_OP_VERSION,
namespace: self.namespace.clone(),
session: self.session.clone(),
key,
patch: patch.into(),
if_match: None,
};
match self
.laser
.execute_kv(Some(&self.namespace), AGDX_KV_PATCH_CODE, &request)
.await?
{
KvOutcome::Versioned { version } => Ok(version),
other => Err(unexpected("patch", &other)),
}
}
pub async fn lease(
&self,
key: impl AsRef<[u8]>,
holder: impl Into<String>,
ttl: Duration,
) -> Result<Lease, LaserError> {
let key = validated_key(key.as_ref())?;
self.require_fenced_leases("lease").await?;
let request = KvLease {
v: KV_LEASE_OP_VERSION,
namespace: self.namespace.clone(),
key,
lease_ttl_micros: duration_micros(ttl),
holder_id: holder.into(),
subject_user_id: None,
};
request.validate()?;
self.laser.acquire_lease(request).await
}
pub async fn renew_lease(
&self,
key: impl AsRef<[u8]>,
holder: impl Into<String>,
token: u64,
ttl: Duration,
) -> Result<Lease, LaserError> {
let key = validated_key(key.as_ref())?;
self.require_fenced_leases("renew_lease").await?;
let request = KvLeaseRenew {
v: KV_LEASE_OP_VERSION,
namespace: self.namespace.clone(),
key,
holder_id: holder.into(),
subject_user_id: None,
lease_token: token,
lease_ttl_micros: duration_micros(ttl),
};
request.validate()?;
match self
.laser
.execute_kv(Some(&self.namespace), AGDX_KV_LEASE_RENEW_CODE, &request)
.await?
{
KvOutcome::Renewed {
lease_token,
granted_ttl_micros,
position,
} => Ok(Lease {
token: lease_token,
granted_ttl: Duration::from_micros(granted_ttl_micros),
position,
}),
other => Err(unexpected("renew_lease", &other)),
}
}
pub async fn release(
&self,
key: impl AsRef<[u8]>,
holder: impl Into<String>,
token: u64,
) -> Result<bool, LaserError> {
let key = validated_key(key.as_ref())?;
self.require_fenced_leases("release").await?;
let request = KvRelease {
v: KV_LEASE_OP_VERSION,
namespace: self.namespace.clone(),
key,
lease_token: token,
holder_id: holder.into(),
};
request.validate()?;
match self
.laser
.execute_kv(Some(&request.namespace), AGDX_KV_RELEASE_CODE, &request)
.await?
{
KvOutcome::Released(released) => Ok(released),
other => Err(unexpected("release", &other)),
}
}
async fn require_fenced_leases(&self, what: &'static str) -> Result<(), LaserError> {
if self.laser.capabilities().await.kv.fenced_leases {
Ok(())
} else {
Err(LaserError::unsupported_feature(
"kv",
what,
"the fenced-lease contract is not advertised by this deployment",
))
}
}
pub fn copy_to(&self, key: impl AsRef<[u8]>, to_key: impl AsRef<[u8]>) -> KvCopyRequest {
KvCopyRequest {
laser: self.laser.clone(),
namespace: self.namespace.clone(),
key: key.as_ref().to_vec(),
to_namespace: None,
to_key: to_key.as_ref().to_vec(),
delete_source: false,
}
}
pub fn move_to(&self, key: impl AsRef<[u8]>, to_key: impl AsRef<[u8]>) -> KvCopyRequest {
KvCopyRequest {
delete_source: true,
..self.copy_to(key, to_key)
}
}
pub async fn get_many(
&self,
keys: impl IntoIterator<Item = impl AsRef<[u8]>>,
) -> Result<Vec<Option<Vec<u8>>>, LaserError> {
let mut ops = Vec::new();
for key in keys {
let request = KvGet {
v: KV_OP_VERSION,
namespace: self.namespace.clone(),
key: validated_key(key.as_ref())?,
if_none_match: None,
min_position: None,
};
ops.push(laser_wire::batch::BatchItem {
code: AGDX_KV_GET_CODE,
payload: encode_named(&request)
.map_err(|error| LaserError::Codec(format!("encode get: {error}")))?,
});
}
let results = self.laser.execute_batch(ops).await?;
results
.iter()
.map(
|slot| match crate::error::decode_managed_reply::<KvReply>(slot)? {
KvReply::Ok(KvOutcome::Value(entry)) => Ok(entry.map(|entry| entry.value)),
KvReply::Ok(ref other) => Err(unexpected("get", other)),
KvReply::Err(error) => Err(error.into()),
_ => Err(LaserError::Protocol(
"unrecognized kv reply in a batch slot".to_owned(),
)),
},
)
.collect()
}
pub fn delete_many(&self) -> KvDeleteManyRequest {
KvDeleteManyRequest {
laser: self.laser.clone(),
namespace: self.namespace.clone(),
prefix: None,
start: None,
end: None,
key_contains: None,
conversation: None,
stream: None,
}
}
pub fn scan(&self) -> KvScanRequest {
KvScanRequest {
laser: self.laser.clone(),
namespace: self.namespace.clone(),
prefix: None,
start: None,
end: None,
key_contains: None,
conversation: None,
stream: None,
limit: DEFAULT_SCAN_LIMIT,
cursor: None,
}
}
}
#[cfg(feature = "agent")]
impl crate::state_store::StateStore for Kv {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, LaserError> {
Kv::get(self, key).await
}
async fn set(&self, key: &str, value: Vec<u8>) -> Result<(), LaserError> {
Kv::set(self, key).bytes(value).send().await
}
async fn delete(&self, key: &str) -> Result<(), LaserError> {
Kv::delete(self, key).await.map(|_existed| ())
}
}
#[must_use = "call .send().await to write, or .commit().await for a compare-and-swap"]
pub struct KvSetRequest {
laser: Laser,
namespace: String,
session: Option<laser_wire::agent::SessionRef>,
key: Vec<u8>,
value: Vec<u8>,
expires_at_micros: Option<u64>,
expect: Option<CasExpect>,
}
impl KvSetRequest {
pub fn bytes(mut self, payload: impl AsRef<[u8]>) -> Self {
self.value = payload.as_ref().to_vec();
self
}
pub fn encode_with<C, T>(mut self, value: &T) -> Result<Self, LaserError>
where
C: Codec<T>,
T: ?Sized,
{
self.value = C::encode(value)?;
Ok(self)
}
pub fn json<T: Serialize + ?Sized>(self, value: &T) -> Result<Self, LaserError> {
self.encode_with::<crate::stream::Json, T>(value)
}
pub fn msgpack<T: Serialize + ?Sized>(self, value: &T) -> Result<Self, LaserError> {
self.encode_with::<crate::stream::Msgpack, T>(value)
}
pub fn ttl(mut self, ttl: Duration) -> Self {
self.expires_at_micros = Some(now_micros().saturating_add(duration_micros(ttl)));
self
}
pub fn expires_at(mut self, epoch_micros: u64) -> Self {
self.expires_at_micros = Some(epoch_micros);
self
}
pub fn expect_version(mut self, version: u64) -> Self {
self.expect = Some(CasExpect::Match(version));
self
}
pub fn expect_absent(mut self) -> Self {
self.expect = Some(CasExpect::Absent);
self
}
pub async fn commit(self) -> Result<u64, LaserError> {
let Some(expect) = self.expect else {
return Err(LaserError::Invalid(
"commit() needs a precondition: call expect_version(..) or expect_absent()"
.to_owned(),
));
};
if !self.laser.capabilities().await.kv.cas {
return Err(LaserError::unsupported_feature(
"kv",
"cas",
"compare-and-swap is not advertised by this deployment",
));
}
let key = validated_key(&self.key)?;
validated_value(&self.value)?;
let request = KvCas {
v: KV_OP_VERSION,
namespace: self.namespace,
session: self.session,
key,
value: self.value,
expires_at_micros: self.expires_at_micros,
expect,
};
match self
.laser
.execute_kv(Some(&request.namespace), AGDX_KV_CAS_CODE, &request)
.await?
{
KvOutcome::Committed { version } => Ok(version),
other => Err(unexpected("cas", &other)),
}
}
pub async fn send(self) -> Result<(), LaserError> {
if self.expect.is_some() {
return Err(LaserError::Invalid(
"a precondition was set: call commit() instead of send()".to_owned(),
));
}
let key = validated_key(&self.key)?;
validated_value(&self.value)?;
let request = KvSet {
v: KV_OP_VERSION,
namespace: self.namespace,
session: self.session,
key,
value: self.value,
expires_at_micros: self.expires_at_micros,
};
match self
.laser
.execute_kv(Some(&request.namespace), AGDX_KV_SET_CODE, &request)
.await?
{
KvOutcome::Written => Ok(()),
other => Err(unexpected("set", &other)),
}
}
}
#[must_use = "call .commit().await to apply the fenced compare-and-swap"]
pub struct KvCasFencedRequest {
laser: Laser,
namespace: String,
key: Vec<u8>,
value: Vec<u8>,
expires_at_micros: Option<u64>,
expect: Option<CasExpect>,
fence_namespace: String,
fence_key: Vec<u8>,
fence_token: u64,
}
impl KvCasFencedRequest {
pub fn bytes(mut self, payload: impl AsRef<[u8]>) -> Self {
self.value = payload.as_ref().to_vec();
self
}
pub fn encode_with<C, T>(mut self, value: &T) -> Result<Self, LaserError>
where
C: Codec<T>,
T: ?Sized,
{
self.value = C::encode(value)?;
Ok(self)
}
pub fn json<T: Serialize + ?Sized>(self, value: &T) -> Result<Self, LaserError> {
self.encode_with::<crate::stream::Json, T>(value)
}
pub fn msgpack<T: Serialize + ?Sized>(self, value: &T) -> Result<Self, LaserError> {
self.encode_with::<crate::stream::Msgpack, T>(value)
}
pub fn ttl(mut self, ttl: Duration) -> Self {
self.expires_at_micros = Some(now_micros().saturating_add(duration_micros(ttl)));
self
}
pub fn expires_at(mut self, epoch_micros: u64) -> Self {
self.expires_at_micros = Some(epoch_micros);
self
}
pub fn expect_version(mut self, version: u64) -> Self {
self.expect = Some(CasExpect::Match(version));
self
}
pub fn expect_absent(mut self) -> Self {
self.expect = Some(CasExpect::Absent);
self
}
pub async fn commit(self) -> Result<u64, LaserError> {
let Some(expect) = self.expect else {
return Err(LaserError::Invalid(
"commit() needs a precondition: call expect_version(..) or expect_absent()"
.to_owned(),
));
};
if !self.laser.capabilities().await.kv.fenced_leases {
return Err(LaserError::unsupported_feature(
"kv",
"cas_fenced",
"the fenced-lease contract is not advertised by this deployment",
));
}
let key = validated_key(&self.key)?;
let fence_key = validated_key(&self.fence_key)?;
validated_value(&self.value)?;
let request = KvCasFenced {
v: KV_LEASE_OP_VERSION,
namespace: self.namespace,
key,
value: self.value,
expires_at_micros: self.expires_at_micros,
expect,
fence_namespace: self.fence_namespace,
fence_key,
fence_token: self.fence_token,
};
request.validate()?;
match self
.laser
.execute_kv(Some(&request.namespace), AGDX_KV_CAS_FENCED_CODE, &request)
.await?
{
KvOutcome::Committed { version } => Ok(version),
other => Err(unexpected("cas_fenced", &other)),
}
}
}
#[must_use = "call .fetch().await or .entries().await to read the page"]
pub struct KvScanRequest {
laser: Laser,
namespace: String,
prefix: Option<Vec<u8>>,
start: Option<Vec<u8>>,
end: Option<Vec<u8>>,
key_contains: Option<String>,
conversation: Option<String>,
stream: Option<String>,
limit: usize,
cursor: Option<Vec<u8>>,
}
impl KvScanRequest {
pub fn conversation(mut self, conversation: ConversationId) -> Self {
self.conversation = Some(conversation.to_string());
self.stream = self.laser.resource_stream().map(str::to_owned);
self
}
pub fn prefix(mut self, prefix: impl AsRef<[u8]>) -> Self {
self.prefix = Some(prefix.as_ref().to_vec());
self
}
pub fn range(mut self, start: impl AsRef<[u8]>, end: impl AsRef<[u8]>) -> Self {
self.start = Some(start.as_ref().to_vec());
self.end = Some(end.as_ref().to_vec());
self
}
pub fn key_contains(mut self, substring: impl Into<String>) -> Self {
self.key_contains = Some(substring.into());
self
}
pub fn limit(mut self, n: usize) -> Self {
self.limit = n.min(laser_wire::limits::MAX_SCAN_LIMIT);
self
}
pub fn cursor(mut self, cursor: impl AsRef<[u8]>) -> Self {
self.cursor = Some(cursor.as_ref().to_vec());
self
}
pub async fn fetch(self) -> Result<KvPage, LaserError> {
let request = self.request();
match self
.laser
.execute_kv(Some(&self.namespace), AGDX_KV_SCAN_CODE, &request)
.await?
{
KvOutcome::Page(page) => Ok(page),
other => Err(unexpected("scan", &other)),
}
}
pub async fn entries(self) -> Result<Vec<KvEntry>, LaserError> {
let laser = self.laser.clone();
let mut request = self.request();
let mut out = Vec::new();
loop {
let page = match laser
.execute_kv(Some(&request.namespace), AGDX_KV_SCAN_CODE, &request)
.await?
{
KvOutcome::Page(page) => page,
other => return Err(unexpected("scan", &other)),
};
out.extend(page.entries);
match page.cursor {
Some(cursor) => request.cursor = Some(cursor),
None => return Ok(out),
}
}
}
#[cfg(feature = "agent")]
pub(crate) fn lens_stream(mut self, stream: Option<String>) -> Self {
self.stream = stream;
self
}
fn request(&self) -> KvScan {
KvScan {
v: KV_OP_VERSION,
namespace: self.namespace.clone(),
prefix: self.prefix.clone(),
start: self.start.clone(),
end: self.end.clone(),
key_contains: self.key_contains.clone(),
conversation: self.conversation.clone(),
stream: self.stream.clone(),
limit: self.limit,
cursor: self.cursor.clone(),
}
}
}
#[must_use = "call .send().await to delete the matched entries"]
pub struct KvDeleteManyRequest {
laser: Laser,
namespace: String,
prefix: Option<Vec<u8>>,
start: Option<Vec<u8>>,
end: Option<Vec<u8>>,
key_contains: Option<String>,
conversation: Option<String>,
stream: Option<String>,
}
impl KvDeleteManyRequest {
pub fn conversation(mut self, conversation: ConversationId) -> Self {
self.conversation = Some(conversation.to_string());
self.stream = self.laser.resource_stream().map(str::to_owned);
self
}
pub fn prefix(mut self, prefix: impl AsRef<[u8]>) -> Self {
self.prefix = Some(prefix.as_ref().to_vec());
self
}
pub fn range(mut self, start: impl AsRef<[u8]>, end: impl AsRef<[u8]>) -> Self {
self.start = Some(start.as_ref().to_vec());
self.end = Some(end.as_ref().to_vec());
self
}
pub fn key_contains(mut self, substring: impl Into<String>) -> Self {
self.key_contains = Some(substring.into());
self
}
pub async fn send(self) -> Result<usize, LaserError> {
let request = KvDeleteMany {
v: KV_OP_VERSION,
namespace: self.namespace,
prefix: self.prefix,
start: self.start,
end: self.end,
key_contains: self.key_contains,
conversation: self.conversation,
stream: self.stream,
};
match self
.laser
.execute_kv(Some(&request.namespace), AGDX_KV_DELETE_MANY_CODE, &request)
.await?
{
KvOutcome::DeletedMany(removed) => Ok(removed),
other => Err(unexpected("delete_many", &other)),
}
}
}
#[must_use = "call .send().await to copy or move the value"]
pub struct KvCopyRequest {
laser: Laser,
namespace: String,
key: Vec<u8>,
to_namespace: Option<String>,
to_key: Vec<u8>,
delete_source: bool,
}
impl KvCopyRequest {
pub fn into_namespace(mut self, namespace: impl Into<String>) -> Self {
self.to_namespace = Some(self.laser.resource_name(&namespace.into()));
self
}
pub async fn send(self) -> Result<u64, LaserError> {
let key = validated_key(&self.key)?;
let to_key = validated_key(&self.to_key)?;
let code = if self.delete_source {
AGDX_KV_MOVE_CODE
} else {
AGDX_KV_COPY_CODE
};
if let Some(to_namespace) = &self.to_namespace {
laser_wire::kv::validate_namespace(to_namespace)?;
}
let request = KvCopy {
v: KV_OP_VERSION,
namespace: self.namespace,
key,
to_namespace: self.to_namespace,
to_key,
};
match self
.laser
.execute_kv(Some(&request.namespace), code, &request)
.await?
{
KvOutcome::Committed { version } => Ok(version),
other => Err(unexpected("copy", &other)),
}
}
}
fn validated_key(key: &[u8]) -> Result<Vec<u8>, LaserError> {
if key.is_empty() {
return Err(LaserError::Invalid("key must not be empty".to_owned()));
}
if key.len() > MAX_KEY_BYTES {
return Err(LaserError::Invalid(format!(
"key is {}B, exceeds cap {MAX_KEY_BYTES}B",
key.len()
)));
}
Ok(key.to_vec())
}
fn validated_value(value: &[u8]) -> Result<(), LaserError> {
if value.len() > MAX_VALUE_BYTES {
return Err(LaserError::Invalid(format!(
"value is {}B, exceeds cap {MAX_VALUE_BYTES}B",
value.len()
)));
}
Ok(())
}
fn unexpected(op: &str, outcome: &KvOutcome) -> LaserError {
LaserError::Protocol(format!("kv {op}: unexpected reply outcome {outcome:?}"))
}
fn now_micros() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(duration_micros)
.unwrap_or(0)
}
fn duration_micros(duration: Duration) -> u64 {
u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capabilities::Capabilities;
fn fenced_laser() -> Laser {
let mut capabilities = Capabilities::OPEN;
capabilities.kv.available = true;
capabilities.kv.fenced_leases = true;
Laser::from_client(crate::iggy::prelude::IggyClient::default())
.with_capabilities(capabilities)
}
fn scoped(naming: crate::laser::ResourceNaming) -> Laser {
fenced_laser()
.with_default_stream("acme")
.with_resource_naming(naming)
}
#[test]
fn given_a_default_stream_when_opening_a_namespace_then_should_send_it_scoped() {
let kv = scoped(crate::laser::ResourceNaming::Stream).kv("sessions");
assert_eq!(kv.resource_namespace(), "stream:acme/sessions");
assert_eq!(kv.namespace(), "sessions");
}
#[test]
fn given_bare_naming_when_opening_a_namespace_then_should_send_it_unchanged() {
let kv = scoped(crate::laser::ResourceNaming::Bare).kv("sessions");
assert_eq!(kv.resource_namespace(), "sessions");
assert_eq!(kv.namespace(), "sessions");
}
#[test]
fn given_a_conversation_lens_when_scanning_then_should_name_the_stream() {
let conversation = ConversationId::derive("lens");
let laser = scoped(crate::laser::ResourceNaming::Stream);
let request = laser
.kv("memory")
.scan()
.conversation(conversation)
.request();
assert_eq!(request.namespace, "stream:acme/memory");
assert_eq!(request.stream.as_deref(), Some("acme"));
assert_eq!(request.conversation, Some(conversation.to_string()));
let unfiltered = laser.kv("memory").scan().request();
assert_eq!(unfiltered.stream, None);
let bare = scoped(crate::laser::ResourceNaming::Bare)
.kv("memory")
.scan()
.conversation(conversation)
.request();
assert_eq!(bare.stream, None);
}
#[test]
fn given_a_conversation_lens_when_deleting_many_then_should_name_the_stream() {
let request = scoped(crate::laser::ResourceNaming::Stream)
.kv("memory")
.delete_many()
.conversation(ConversationId::derive("lens"));
assert_eq!(request.namespace, "stream:acme/memory");
assert_eq!(request.stream.as_deref(), Some("acme"));
}
#[test]
fn given_fence_and_copy_namespaces_when_built_then_should_scope_both() {
let kv = scoped(crate::laser::ResourceNaming::Stream).kv("state");
let fenced = kv.cas_fenced("key", "coordination", "lease", 1);
assert_eq!(fenced.namespace, "stream:acme/state");
assert_eq!(fenced.fence_namespace, "stream:acme/coordination");
let copy = kv.copy_to("a", "b").into_namespace("archive");
assert_eq!(copy.to_namespace.as_deref(), Some("stream:acme/archive"));
}
#[test]
fn given_listed_namespaces_when_localized_then_should_keep_only_the_own_stream() {
let info = |namespace: &str| KvNamespaceInfo {
namespace: namespace.to_owned(),
entries: 1,
};
let listed = vec![
info("stream:acme/sessions"),
info("stream:other/sessions"),
info("legacy"),
];
let local = scoped(crate::laser::ResourceNaming::Stream).local_namespaces(listed.clone());
assert_eq!(
local
.iter()
.map(|info| info.namespace.as_str())
.collect::<Vec<_>>(),
["sessions"]
);
let bare = scoped(crate::laser::ResourceNaming::Bare).local_namespaces(listed);
assert_eq!(bare.len(), 3);
}
#[test]
fn given_an_empty_key_when_validated_then_should_error() {
assert!(matches!(validated_key(b""), Err(LaserError::Invalid(_))));
}
#[test]
fn given_an_oversized_key_when_validated_then_should_error() {
let key = vec![b'x'; MAX_KEY_BYTES + 1];
assert!(matches!(validated_key(&key), Err(LaserError::Invalid(_))));
}
#[test]
fn given_a_binary_key_when_validated_then_should_accept_it() {
assert!(validated_key(&[0xff, 0x00, 0xfe]).is_ok());
}
#[test]
fn given_an_extreme_duration_when_converted_then_should_saturate() {
assert_eq!(duration_micros(Duration::MAX), u64::MAX);
}
#[tokio::test]
async fn given_invalid_lease_inputs_when_called_then_should_reject_before_transport() {
let kv = fenced_laser().kv("coordination");
assert!(matches!(
kv.lease("key", "", Duration::from_secs(1)).await,
Err(LaserError::Invalid(_))
));
assert!(matches!(
kv.lease("key", "holder", Duration::ZERO).await,
Err(LaserError::Invalid(_))
));
assert!(matches!(
kv.lease("key", "x".repeat(129), Duration::from_secs(1))
.await,
Err(LaserError::Invalid(_))
));
}
#[tokio::test]
async fn given_invalid_renew_release_when_called_then_should_reject_before_transport() {
let kv = fenced_laser().kv("coordination");
assert!(matches!(
kv.renew_lease("key", "holder", 1, Duration::ZERO).await,
Err(LaserError::Invalid(_))
));
assert!(matches!(
kv.renew_lease("key", "holder", 0, Duration::from_secs(1))
.await,
Err(LaserError::Invalid(_))
));
assert!(matches!(
kv.release("key", "holder", 0).await,
Err(LaserError::Invalid(_))
));
}
#[tokio::test]
async fn given_an_empty_key_when_expiring_at_then_should_reject_before_transport() {
let kv = fenced_laser().kv("state");
assert!(matches!(
kv.expire_at("", Some(1)).await,
Err(LaserError::Invalid(_))
));
assert!(matches!(
kv.expire_at("", None).await,
Err(LaserError::Invalid(_))
));
}
#[tokio::test]
async fn given_a_precondition_when_sending_then_should_reject_before_transport() {
for request in [
fenced_laser()
.kv("state")
.set("key")
.bytes(b"value")
.expect_absent(),
fenced_laser()
.kv("state")
.set("key")
.bytes(b"value")
.expect_version(3),
] {
assert!(matches!(request.send().await, Err(LaserError::Invalid(_))));
}
}
#[tokio::test]
async fn given_an_invalid_fence_namespace_when_committing_then_should_reject_before_transport()
{
let result = fenced_laser()
.kv("state")
.cas_fenced("key", "", "lease", 1)
.bytes(b"value")
.expect_absent()
.commit()
.await;
assert!(matches!(result, Err(LaserError::Invalid(_))));
}
#[test]
fn given_a_kv_error_when_converted_then_should_nest_the_typed_error() {
let unsupported = LaserError::from(KvError::Unsupported("no managed backend".to_owned()));
assert!(matches!(
unsupported,
LaserError::Kv(KvError::Unsupported(_))
));
assert!(unsupported.is_unsupported());
let backend = LaserError::from(KvError::Backend("boom".to_owned()));
assert!(matches!(backend, LaserError::Kv(KvError::Backend(_))));
assert!(!backend.is_retryable());
let skew = LaserError::from(KvError::Version {
expected: 1,
got: 2,
});
assert!(matches!(skew, LaserError::Kv(KvError::Version { .. })));
assert!(skew.is_version_skew());
assert!(!skew.is_retryable());
}
}