From aa3db9b4cc90c9c2e880d06c893e277288e50a9a Mon Sep 17 00:00:00 2001 From: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:18:18 +0700 Subject: [PATCH] Make the KvHandle TTL cap configurable per handle --- crates/edgezero-core/src/key_value_store.rs | 130 ++++++++++++++++++-- docs/guide/kv.md | 4 +- 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/crates/edgezero-core/src/key_value_store.rs b/crates/edgezero-core/src/key_value_store.rs index 2963c425b..2ad9367d5 100644 --- a/crates/edgezero-core/src/key_value_store.rs +++ b/crates/edgezero-core/src/key_value_store.rs @@ -355,6 +355,7 @@ pub enum KvError { /// ``` #[derive(Clone)] pub struct KvHandle { + max_ttl: Duration, store: Arc, } @@ -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)" @@ -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) -> Self { - Self { store } + Self { + max_ttl: Self::MAX_TTL, + store, + } } /// Put a value, serializing it to JSON. @@ -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(); @@ -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(); @@ -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(()) @@ -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 for EdgeError { @@ -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 @@ -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, @@ -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 = 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(); diff --git a/docs/guide/kv.md b/docs/guide/kv.md index 86a39b826..f9c54005e 100644 --- a/docs/guide/kv.md +++ b/docs/guide/kv.md @@ -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 @@ -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 |