Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 122 additions & 8 deletions crates/edgezero-core/src/key_value_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ pub enum KvError {
/// ```
#[derive(Clone)]
pub struct KvHandle {
max_ttl: Duration,
store: Arc<dyn KvStore>,
}

Expand All @@ -372,7 +373,9 @@ impl KvHandle {
/// Maximum number of keys returned from a single page.
pub const MAX_LIST_PAGE_SIZE: usize = 1_000;

/// Maximum TTL (1 year). Prevents overflow when adding to `SystemTime::now()`.
/// Default maximum TTL (1 year), applied by [`KvHandle::new`].
///
/// Override it per handle with [`KvHandle::with_max_ttl`].
#[expect(
clippy::duration_suboptimal_units,
reason = "`Duration::from_days` is not stable in const context (1.95)"
Expand Down Expand Up @@ -608,10 +611,21 @@ impl KvHandle {
})
}

/// The largest TTL this handle accepts in `put_with_ttl` /
/// `put_bytes_with_ttl`. Defaults to [`KvHandle::MAX_TTL`].
#[inline]
#[must_use]
pub const fn max_ttl(&self) -> Duration {
self.max_ttl
}

/// Create a new handle wrapping a KV store implementation.
#[inline]
pub fn new(store: Arc<dyn KvStore>) -> Self {
Self { store }
Self {
max_ttl: Self::MAX_TTL,
store,
}
}

/// Put a value, serializing it to JSON.
Expand Down Expand Up @@ -661,7 +675,7 @@ impl KvHandle {
ttl: Duration,
) -> Result<(), KvError> {
Self::validate_key(key)?;
Self::validate_ttl(ttl)?;
self.validate_ttl(ttl)?;
Self::validate_value(&value)?;
let bytes_len = value.len();
let started_at = Self::kv_timing_start();
Expand All @@ -684,7 +698,7 @@ impl KvHandle {
ttl: Duration,
) -> Result<(), KvError> {
Self::validate_key(key)?;
Self::validate_ttl(ttl)?;
self.validate_ttl(ttl)?;
let bytes = serde_json::to_vec(value)?;
Self::validate_value(&bytes)?;
let bytes_len = bytes.len();
Expand Down Expand Up @@ -788,15 +802,16 @@ impl KvHandle {
Ok(())
}

fn validate_ttl(ttl: Duration) -> Result<(), KvError> {
fn validate_ttl(&self, ttl: Duration) -> Result<(), KvError> {
if ttl < Self::MIN_TTL {
return Err(KvError::Validation(format!(
"TTL {ttl:?} is less than minimum of at least 60 seconds"
)));
}
if ttl > Self::MAX_TTL {
if ttl > self.max_ttl {
return Err(KvError::Validation(format!(
"TTL {ttl:?} exceeds maximum of 1 year"
"TTL {ttl:?} exceeds maximum of {:?}",
self.max_ttl
)));
}
Ok(())
Expand All @@ -812,6 +827,20 @@ impl KvHandle {
}
Ok(())
}

/// Return this handle with a different TTL cap.
///
/// Use this when an application's retention policy needs entries to
/// live longer than [`KvHandle::MAX_TTL`] (or should be capped lower).
/// The cap applies to this handle and its clones only. The
/// [`KvHandle::MIN_TTL`] floor still applies, so a cap below it rejects
/// every TTL write.
#[inline]
#[must_use]
pub const fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
self.max_ttl = max_ttl;
self
}
}

impl From<KvError> for EdgeError {
Expand Down Expand Up @@ -866,7 +895,7 @@ pub struct KvPage {
/// delegating here. Implementations may therefore assume that:
/// - Keys are non-empty and within [`KvHandle::MAX_KEY_SIZE`]
/// - Values are within [`KvHandle::MAX_VALUE_SIZE`]
/// - TTLs are within `[MIN_TTL, MAX_TTL]`
/// - TTLs are within `[MIN_TTL, max_ttl]` (see [`KvHandle::with_max_ttl`])
/// - List limits are within `[1, MAX_LIST_PAGE_SIZE]`
///
/// Do **not** call trait methods directly in production code; always go
Expand Down Expand Up @@ -999,6 +1028,8 @@ mod tests {
use std::sync::Mutex;
use std::time::SystemTime;

const TWO_YEARS: Duration = KvHandle::MAX_TTL.saturating_mul(2);

#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct Counter {
count: i32,
Expand Down Expand Up @@ -1615,6 +1646,89 @@ mod tests {
});
}

#[test]
fn default_max_ttl_is_max_ttl_constant() {
let kv = handle();
assert_eq!(kv.max_ttl(), KvHandle::MAX_TTL);
}

#[test]
fn with_max_ttl_accepts_ttl_beyond_default_cap() {
let kv = handle().with_max_ttl(TWO_YEARS);
block_on(async {
kv.put_with_ttl("retained", &"val", TWO_YEARS)
.await
.unwrap();
kv.put_bytes_with_ttl("retained_bytes", Bytes::from("raw"), TWO_YEARS)
.await
.unwrap();
let val: Option<String> = kv.get("retained").await.unwrap();
assert_eq!(val, Some("val".to_owned()));
assert_eq!(
kv.get_bytes("retained_bytes").await.unwrap(),
Some(Bytes::from("raw"))
);
});
}

#[test]
fn with_max_ttl_rejects_ttl_beyond_custom_cap() {
let kv = handle().with_max_ttl(TWO_YEARS);
block_on(async {
let over = TWO_YEARS + Duration::from_secs(1);
let err = kv.put_with_ttl("long", &"val", over).await.unwrap_err();
assert!(matches!(err, KvError::Validation(_)));
assert!(format!("{err}").contains("exceeds maximum of 63072000s"));
let bytes_err = kv
.put_bytes_with_ttl("long", Bytes::from("raw"), over)
.await
.unwrap_err();
assert!(matches!(bytes_err, KvError::Validation(_)));
assert!(!kv.exists("long").await.unwrap());
});
}

#[test]
fn with_max_ttl_can_lower_the_cap() {
let kv = handle().with_max_ttl(Duration::from_hours(1));
block_on(async {
kv.put_with_ttl("short", &"val", Duration::from_hours(1))
.await
.unwrap();
let err = kv
.put_with_ttl("short", &"val", Duration::from_secs(3601))
.await
.unwrap_err();
assert!(matches!(err, KvError::Validation(_)));
});
}

#[test]
fn with_max_ttl_keeps_min_ttl_floor() {
let kv = handle().with_max_ttl(TWO_YEARS);
block_on(async {
let err = kv
.put_with_ttl("tiny", &"val", Duration::from_secs(59))
.await
.unwrap_err();
assert!(matches!(err, KvError::Validation(_)));
assert!(format!("{err}").contains("less than minimum"));
});
}

#[test]
fn with_max_ttl_is_preserved_by_clone() {
let kv = handle().with_max_ttl(TWO_YEARS);
let cloned = kv.clone();
assert_eq!(cloned.max_ttl(), TWO_YEARS);
block_on(async {
cloned
.put_with_ttl("retained", &"val", TWO_YEARS)
.await
.unwrap();
});
}

#[test]
fn validation_rejects_malformed_list_cursor() {
let kv = handle();
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/kv.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Both Fastly and Cloudflare KV stores are **eventually consistent**.

- A value written at one edge location may not be immediately visible at another.
- `read_modify_write()` is **not atomic**. Concurrent updates to the same key may result in lost writes.
- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and a maximum of **1 year** before delegating to an adapter. Spin KV does not support TTL, so the Spin adapter returns `KvError::Unsupported { operation: "put_bytes_with_ttl" }` without writing the value.
- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and, by default, a maximum of **1 year** before delegating to an adapter. Applications with longer retention windows can raise the cap per handle with `KvHandle::with_max_ttl` (for example `kv.named("events").map(|store| store.with_max_ttl(Duration::from_secs(3 * 365 * 24 * 60 * 60)))`); `max_ttl()` reports the active cap. Spin KV does not support TTL, so the Spin adapter returns `KvError::Unsupported { operation: "put_bytes_with_ttl" }` without writing the value.

## Limits & Validation

Expand All @@ -187,7 +187,7 @@ strictest common limits:
| Key size | Max **512 bytes** (Cloudflare limit) |
| Value size | Max **25 MB** |
| TTL minimum | **60 seconds** |
| TTL maximum | **1 year** |
| TTL maximum | **1 year** (default; `with_max_ttl`) |
| List page max | **1000 keys** |
| Empty keys | Rejected |
| Reserved keys | `.` and `..` rejected |
Expand Down