diff --git a/.changeset/remote-native-blob-cache.md b/.changeset/remote-native-blob-cache.md new file mode 100644 index 000000000..814eb6d8c --- /dev/null +++ b/.changeset/remote-native-blob-cache.md @@ -0,0 +1,7 @@ +--- +"@reddb-io/cli": minor +"@reddb-io/client": minor +"@reddb-io/sdk": minor +--- + +Expose the native Blob Cache over authenticated HTTP with tenant-scoped namespaces, hard TTL, binary values, existence probes and key/prefix/tag/namespace invalidation. The remote JavaScript client now uses application-authorized endpoints for tag invalidation and namespace flush, without requiring an operator credential. Align cache policy types with the implemented HTTP contract. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21e9b7a96..e2a51e348 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,13 @@ jobs: fi - name: Build RedWire server run: cargo build --locked --bin red + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '22' + - name: Remote HTTP native Blob Cache regression + env: + RED_BIN: ${{ github.workspace }}/target/debug/red + run: node scripts/test-remote-cache.mjs - name: Run Rust driver conformance over RedWire env: RED_BIN: ${{ github.workspace }}/target/debug/red diff --git a/Cargo.lock b/Cargo.lock index 9b0ff7e76..895fc6687 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2639,9 +2639,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "bytes", "getrandom 0.4.2", @@ -2940,6 +2940,7 @@ dependencies = [ "aes-gcm", "argon2", "axum", + "base64 0.22.1", "blake3", "bytes", "crc32fast", @@ -3244,9 +3245,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", diff --git a/crates/reddb-server/Cargo.toml b/crates/reddb-server/Cargo.toml index 678636e2f..5364f0aea 100644 --- a/crates/reddb-server/Cargo.toml +++ b/crates/reddb-server/Cargo.toml @@ -32,6 +32,7 @@ reddb-file = { workspace = true } reddb-crypto = { workspace = true } aes-gcm = "0.11" blake3 = "1" +base64 = "0.22" # SIMD/PCLMULQDQ CRC32 (IEEE 802.3 polynomial) for WAL/page record # checksums (issue #883 / PRD #882). Runtime CPU feature detection # selects the pclmulqdq path with a table-based scalar fallback — diff --git a/crates/reddb-server/src/auth/action_catalog.rs b/crates/reddb-server/src/auth/action_catalog.rs index 7b80d3256..0f24438e1 100644 --- a/crates/reddb-server/src/auth/action_catalog.rs +++ b/crates/reddb-server/src/auth/action_catalog.rs @@ -157,6 +157,24 @@ pub struct ActionEntry { /// delete the row. To remove one: change it to `Removed` (and only /// delete after a release cycle). pub const ACTIONS: &[ActionEntry] = &[ + ActionEntry { + name: "cache:read", + category: ActionCategory::Other, + lifecycle_state: LifecycleState::Active, + gates_description: "read or probe a native Blob Cache namespace", + }, + ActionEntry { + name: "cache:write", + category: ActionCategory::Other, + lifecycle_state: LifecycleState::Active, + gates_description: "write values to a native Blob Cache namespace", + }, + ActionEntry { + name: "cache:invalidate", + category: ActionCategory::Other, + lifecycle_state: LifecycleState::Active, + gates_description: "invalidate or flush a native Blob Cache namespace", + }, // -- DML / DDL / privilege management -------------------------------- ActionEntry { name: "select", diff --git a/crates/reddb-server/src/server.rs b/crates/reddb-server/src/server.rs index f69418531..b523e04e2 100644 --- a/crates/reddb-server/src/server.rs +++ b/crates/reddb-server/src/server.rs @@ -115,6 +115,7 @@ mod handlers_ai_model_cache; pub(crate) mod handlers_auth; mod handlers_backup; mod handlers_browser_auth; +mod handlers_cache; mod handlers_capabilities; mod handlers_collection_policy; mod handlers_ec; diff --git a/crates/reddb-server/src/server/handlers_cache.rs b/crates/reddb-server/src/server/handlers_cache.rs new file mode 100644 index 000000000..7f1ad003e --- /dev/null +++ b/crates/reddb-server/src/server/handlers_cache.rs @@ -0,0 +1,787 @@ +//! Remote, tenant-scoped access to the existing native Blob Cache. +//! Application namespaces never address the runtime's internal result cache. +use super::*; +use crate::auth::policies::{EvalContext, ResourceRef}; +use crate::serde_json::JsonDecode; +use crate::server::handlers_auth::resolve_auth_caller; +use crate::server::route_catalog::RouteRequest; +use crate::storage::cache::blob::{ + BlobCachePolicy, BlobCachePut, CacheError, CachePresence, L1Admission, +}; +use crate::storage::cache::sweeper::BlobCacheSweeper; +use base64::{engine::general_purpose::STANDARD, Engine}; + +const VALUE_BYTES_MAX: usize = 1024 * 1024; +const KEY_BYTES_MAX: usize = 4096; +const NAMESPACE_BYTES_MAX: usize = 256; +const TAG_COUNT_MAX: usize = 64; + +#[derive(Default)] +struct CachePolicyInput { + ttl_ms: Option, + expires_at_unix_ms: Option, + max_blob_bytes: Option, + l1_admission: Option, + priority: Option, + version: Option, +} + +struct CachePutInput { + value: String, + ttl_ms: Option, + tags: Vec, + policy: CachePolicyInput, +} + +struct CacheTagsInput { + tags: Vec, +} + +fn cache_fields(value: &JsonValue, allowed: &[&str]) -> Result<(), String> { + let fields = value.as_object().ok_or("cache body must be an object")?; + if fields.keys().any(|name| !allowed.contains(&name.as_str())) { + return Err("unknown cache field".into()); + } + Ok(()) +} + +fn cache_field(value: &JsonValue, name: &str) -> Result, String> { + value.get(name).cloned().map(T::from_json_value).transpose() +} + +fn cache_integer(value: &JsonValue, name: &str) -> Result, String> { + value + .get(name) + .map(|number| match number { + JsonValue::Integer(number) => { + u64::try_from(*number).map_err(|_| "negative cache integer".into()) + } + JsonValue::Number(number) + if number.is_finite() + && *number >= 0.0 + && number.fract() == 0.0 + && *number <= 9_007_199_254_740_991.0 => + { + number + .to_string() + .parse::() + .map_err(|_| "invalid cache integer".into()) + } + _ => Err("invalid cache integer".into()), + }) + .transpose() +} + +impl JsonDecode for CachePutInput { + fn from_json_value(value: JsonValue) -> Result { + cache_fields(&value, &["value", "ttl_ms", "tags", "policy"])?; + Ok(Self { + value: cache_field(&value, "value")?.ok_or("cache value is required")?, + ttl_ms: cache_integer(&value, "ttl_ms")?, + tags: cache_field(&value, "tags")?.unwrap_or_default(), + policy: cache_field(&value, "policy")?.unwrap_or_default(), + }) + } +} + +impl JsonDecode for CachePolicyInput { + fn from_json_value(value: JsonValue) -> Result { + cache_fields( + &value, + &[ + "ttl_ms", + "expires_at_unix_ms", + "max_blob_bytes", + "l1_admission", + "priority", + "version", + ], + )?; + Ok(Self { + ttl_ms: cache_integer(&value, "ttl_ms")?, + expires_at_unix_ms: cache_integer(&value, "expires_at_unix_ms")?, + max_blob_bytes: cache_integer(&value, "max_blob_bytes")? + .map(usize::try_from) + .transpose() + .map_err(|_| "cache size is too large")?, + l1_admission: cache_field(&value, "l1_admission")?, + priority: cache_integer(&value, "priority")? + .map(u8::try_from) + .transpose() + .map_err(|_| "cache priority is too large")?, + version: cache_integer(&value, "version")?, + }) + } +} + +impl JsonDecode for CacheTagsInput { + fn from_json_value(value: JsonValue) -> Result { + cache_fields(&value, &["tags"])?; + Ok(Self { + tags: cache_field(&value, "tags")?.ok_or("cache tags are required")?, + }) + } +} + +impl RedDBServer { + pub(crate) fn handle_cache( + &self, + request: &RouteRequest<'_>, + operation: &str, + action: &str, + ) -> HttpResponse { + let mut response = self + .handle_cache_result(request, operation, action) + .unwrap_or_else(|response| response); + response.extra_headers.push(( + "Cache-Control", + header_escape_guard::HeaderEscapeGuard::header_value("no-store") + .expect("static cache-control header is safe"), + )); + response + } + + fn handle_cache_result( + &self, + request: &RouteRequest<'_>, + operation: &str, + action: &str, + ) -> Result { + let namespace = + cache_segment(request.matched.params.get("namespace"), NAMESPACE_BYTES_MAX)?; + let namespace = self.cache_namespace(request.headers, &namespace, action)?; + let cache = self.runtime.result_blob_cache(); + let result = match operation { + "put" => { + // Bound base64 before decoding or allocating the raw payload. + if request.body.len() > VALUE_BYTES_MAX * 2 { + return Err(json_error(413, "cache request is too large")); + } + let input: CachePutInput = cache_json(request.body)?; + validate_tags(&input.tags)?; + if input.value.len() > VALUE_BYTES_MAX.div_ceil(3) * 4 { + return Err(json_error(413, "cache value is too large")); + } + let value = STANDARD + .decode(&input.value) + .map_err(|_| json_error(400, "cache value must be standard base64"))?; + if value.len() > VALUE_BYTES_MAX { + return Err(json_error(413, "cache value is too large")); + } + let policy = cache_policy(input.ttl_ms, input.policy)?; + let key = cache_segment(request.matched.params.get("key"), KEY_BYTES_MAX)?; + cache + .put( + namespace, + key, + BlobCachePut::new(value) + .with_tags(input.tags) + .with_policy(policy), + ) + .map_err(cache_error)?; + crate::json!({ "ok": true }) + } + "get" => { + let key = cache_segment(request.matched.params.get("key"), KEY_BYTES_MAX)?; + match cache.get(&namespace, &key) { + Some(hit) => crate::json!({ "value": STANDARD.encode(hit.value()) }), + None => crate::json!({ "value": JsonValue::Null }), + } + } + "exists" => { + let key = cache_segment(request.matched.params.get("key"), KEY_BYTES_MAX)?; + let presence = match cache.exists(&namespace, &key) { + CachePresence::Present => "present", + CachePresence::Absent => "absent", + CachePresence::MaybePresent => "maybe", + }; + crate::json!({ "status": presence }) + } + "invalidate" => { + let key = cache_segment(request.matched.params.get("key"), KEY_BYTES_MAX)?; + crate::json!({ "removed": cache.invalidate_key(&namespace, &key) }) + } + "invalidate_prefix" => { + let prefix = request + .query + .get("prefix") + .ok_or_else(|| json_error(400, "prefix is required"))?; + // The transport keeps query values encoded; decode exactly once, + // including form-style spaces, before matching literal key bytes. + let prefix = percent_decode_path_segment(&prefix.replace('+', " ")) + .map_err(|_| json_error(400, "invalid cache prefix encoding"))?; + validate_cache_text(&prefix, KEY_BYTES_MAX, true)?; + crate::json!({ "removed": cache.invalidate_prefix(&namespace, &prefix) }) + } + "invalidate_tags" => { + let input: CacheTagsInput = cache_json(request.body)?; + validate_tags(&input.tags)?; + let tags: Vec<&str> = input.tags.iter().map(String::as_str).collect(); + crate::json!({ "removed": cache.invalidate_tags(&namespace, &tags) }) + } + "flush" => { + BlobCacheSweeper::flush_namespace(cache, &namespace); + crate::json!({ "ok": true }) + } + _ => return Err(json_error(404, "unknown cache operation")), + }; + Ok(json_response(200, result)) + } + + fn cache_namespace( + &self, + headers: &BTreeMap, + namespace: &str, + action: &str, + ) -> Result { + let caller = resolve_auth_caller(self, headers).or_else(|| { + let validator = self.runtime.oauth_validator()?; + let token = headers.get("authorization")?.strip_prefix("Bearer ")?; + let (tenant, username, role) = + crate::wire::redwire::auth::validate_oauth_jwt_full(&validator, token).ok()?; + Some(crate::server::handlers_auth::AuthCaller { + id: crate::auth::UserId::from_parts(tenant.as_deref(), &username), + role, + }) + }); + if (self + .auth_store + .as_ref() + .is_some_and(|store| store.is_enabled()) + || self.runtime.oauth_validator().is_some()) + && caller.is_none() + { + return Err(json_error( + 401, + "cache access requires an authenticated user", + )); + } + let tenant = caller + .as_ref() + .and_then(|caller| caller.id.tenant.as_deref()); + if let Some(caller) = &caller { + let allowed = if action == "cache:read" { + caller.role.can_read() + } else { + caller.role.can_write() + }; + if !allowed { + return Err(json_error( + 403, + "cache operation is not allowed for this role", + )); + } + if let Some(store) = self + .auth_store + .as_ref() + .filter(|store| store.iam_authorization_enabled()) + { + let resource = match tenant { + Some(tenant) => ResourceRef::new("cache", namespace).with_tenant(tenant), + None => ResourceRef::new("cache", namespace), + }; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |time| time.as_millis()); + let context = EvalContext { + principal_tenant: caller.id.tenant.clone(), + current_tenant: caller.id.tenant.clone(), + peer_ip: None, + mfa_present: false, + now_ms, + principal_is_admin_role: caller.role.can_admin(), + principal_is_platform_scoped: tenant.is_none(), + }; + if !store.check_policy_authz_with_role( + &caller.id, + action, + &resource, + &context, + caller.role, + ) { + return Err(json_error(403, "cache operation denied by IAM policy")); + } + } + } + // A length-delimited tenant prefix distinguishes platform scope, empty + // tenant IDs and caller-controlled namespaces, including separator bytes. + Ok(match tenant { + Some(tenant) => format!("http-cache:t{}:{tenant}:{namespace}", tenant.len()), + None => format!("http-cache:p:{namespace}"), + }) + } +} + +fn cache_segment(value: Option<&String>, limit: usize) -> Result { + let value = value.ok_or_else(|| json_error(400, "cache path segment is required"))?; + let value = percent_decode_path_segment(value) + .map_err(|_| json_error(400, "invalid cache path encoding"))?; + validate_cache_text(&value, limit, false)?; + Ok(value) +} + +fn validate_cache_text(value: &str, limit: usize, allow_empty: bool) -> Result<(), HttpResponse> { + if (!allow_empty && value.is_empty()) + || value.len() > limit + || value.chars().any(char::is_control) + { + return Err(json_error(400, "invalid cache key, namespace or tag")); + } + Ok(()) +} + +fn validate_tags(tags: &[String]) -> Result<(), HttpResponse> { + if tags.len() > TAG_COUNT_MAX { + return Err(json_error(400, "too many cache tags")); + } + for tag in tags { + validate_cache_text(tag, NAMESPACE_BYTES_MAX, false)?; + } + Ok(()) +} + +fn cache_json(body: &[u8]) -> Result { + crate::serde_json::from_slice(body).map_err(|_| json_error(400, "invalid cache JSON body")) +} + +fn cache_policy( + ttl_ms: Option, + input: CachePolicyInput, +) -> Result { + if ttl_ms.is_some() && input.ttl_ms.is_some() && ttl_ms != input.ttl_ms { + return Err(json_error(400, "conflicting cache TTL values")); + } + let mut policy = BlobCachePolicy::default().max_blob_bytes(VALUE_BYTES_MAX); + if let Some(ttl) = ttl_ms.or(input.ttl_ms) { + policy = policy.ttl_ms(ttl); + } + if let Some(expires) = input.expires_at_unix_ms { + policy = policy.expires_at_unix_ms(expires); + } + if let Some(size) = input.max_blob_bytes { + policy = policy.max_blob_bytes(size.min(VALUE_BYTES_MAX)); + } + if let Some(priority) = input.priority { + policy = policy.priority(priority); + } + if let Some(version) = input.version { + policy = policy.version(version); + } + if let Some(admission) = input.l1_admission { + policy = policy.l1_admission(match admission.as_str() { + "auto" => L1Admission::Auto, + "always" => L1Admission::Always, + "never" => L1Admission::Never, + _ => return Err(json_error(400, "invalid cache L1 admission")), + }); + } + Ok(policy) +} + +fn cache_error(error: CacheError) -> HttpResponse { + match error { + CacheError::BlobTooLarge { .. } | CacheError::MetadataTooLarge { .. } => { + json_error(413, "cache value or metadata is too large") + } + CacheError::VersionMismatch { .. } => json_error(409, "cache version conflict"), + CacheError::TooManyNamespaces { .. } => { + json_error(429, "cache namespace capacity exceeded") + } + CacheError::L2Full { .. } | CacheError::L2Io(_) => { + json_error(503, "cache storage is unavailable") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::policies::Policy; + use crate::auth::store::PrincipalRef; + use crate::auth::{AuthConfig, AuthStore, Role, UserId}; + + fn server() -> RedDBServer { + RedDBServer::new( + RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("test runtime"), + ) + } + + fn request( + server: &RedDBServer, + method: &str, + path: &str, + body: &str, + token: Option<&str>, + ) -> HttpResponse { + let (path, prefix) = path + .split_once("?prefix=") + .map_or((path, None), |(path, prefix)| (path, Some(prefix))); + let mut headers = BTreeMap::new(); + if let Some(token) = token { + headers.insert("authorization".into(), format!("Bearer {token}")); + } + // Caller-controlled tenant headers cannot select another cache partition. + headers.insert("x-reddb-tenant".into(), "foreign".into()); + let query = prefix + .map(|prefix| BTreeMap::from([("prefix".into(), prefix.into())])) + .unwrap_or_default(); + server.route(HttpRequest { + method: method.into(), + path: path.into(), + query, + headers, + body: body.as_bytes().to_vec(), + }) + } + + fn value(response: HttpResponse) -> ::serde_json::Value { + assert_eq!( + response.status, + 200, + "{}", + String::from_utf8_lossy(&response.body) + ); + ::serde_json::from_slice(&response.body).expect("JSON cache response") + } + + fn put(server: &RedDBServer, namespace: &str, key: &str, bytes: &[u8], token: Option<&str>) { + let body = ::serde_json::json!({ "value": STANDARD.encode(bytes), "ttl_ms": 600_000, "tags": ["daily"] }).to_string(); + value(request( + server, + "PUT", + &format!("/cache/ns/{namespace}/{key}"), + &body, + token, + )); + } + + #[test] + fn native_cache_http_binary_roundtrip_ttl_and_reserved_key_names() { + let server = server(); + for key in [ + "tokens", + "tags", + "flush", + "invalidate-tags", + "a%2Fb%3Ac%25%20%E2%98%83", + ] { + put(&server, "bigquery", key, &[0, 255, 128, 13, 10], None); + let response = request( + &server, + "GET", + &format!("/cache/ns/bigquery/{key}"), + "", + None, + ); + assert!(response + .extra_headers + .iter() + .any(|(name, value)| *name == "Cache-Control" && value == "no-store")); + assert_eq!( + value(response)["value"], + STANDARD.encode([0, 255, 128, 13, 10]) + ); + value(request( + &server, + "DELETE", + &format!("/cache/ns/bigquery/{key}"), + "", + None, + )); + assert!(value(request( + &server, + "GET", + &format!("/cache/ns/bigquery/{key}"), + "", + None + ))["value"] + .is_null()); + } + value(request( + &server, + "PUT", + "/cache/ns/bigquery/short", + r#"{"value":"YQ==","ttl_ms":1}"#, + None, + )); + std::thread::sleep(Duration::from_millis(10)); + assert!(value(request( + &server, + "GET", + "/cache/ns/bigquery/short", + "", + None + ))["value"] + .is_null()); + assert_eq!( + value(request( + &server, + "GET", + "/cache/ns/bigquery/short/exists", + "", + None + ))["status"], + "absent" + ); + } + + #[test] + fn native_cache_http_invalidations_never_address_internal_or_other_namespaces() { + let server = server(); + server + .runtime + .result_blob_cache() + .put( + "runtime.result_cache", + "secret", + BlobCachePut::new(b"internal".to_vec()), + ) + .expect("internal cache value"); + assert!(value(request( + &server, + "GET", + "/cache/ns/runtime.result_cache/secret", + "", + None + ))["value"] + .is_null()); + for namespace in ["reports", "other"] { + for key in ["daily:1", "daily:2", "monthly"] { + put(&server, namespace, key, b"ok", None); + } + } + assert_eq!( + value(request( + &server, + "DELETE", + "/cache/ns/reports?prefix=daily%3A", + "", + None + ))["removed"], + 2 + ); + assert_eq!( + value(request( + &server, + "POST", + "/cache/ns/reports/invalidate-tags", + r#"{"tags":["daily"]}"#, + None + ))["removed"], + 1 + ); + assert!(value(request( + &server, + "GET", + "/cache/ns/reports/monthly", + "", + None + ))["value"] + .is_null()); + put(&server, "reports", "again", b"ok", None); + value(request( + &server, + "POST", + "/cache/ns/reports/flush", + "", + None, + )); + assert!( + value(request(&server, "GET", "/cache/ns/reports/again", "", None))["value"].is_null() + ); + assert_eq!( + value(request(&server, "GET", "/cache/ns/other/monthly", "", None))["value"], + "b2s=" + ); + value(request( + &server, + "POST", + "/cache/ns/runtime.result_cache/flush", + "", + None, + )); + assert_eq!( + server + .runtime + .result_blob_cache() + .get("runtime.result_cache", "secret") + .expect("internal value survived") + .value(), + b"internal" + ); + } + + #[test] + fn native_cache_http_validates_input_before_touching_cache() { + let server = server(); + for body in [ + "not JSON", + r#"{"value":"??"}"#, + r#"{"value":"YQ==","ttl_ms":-1}"#, + r#"{"value":"YQ==","ttl_ms":10,"policy":{"ttl_ms":20}}"#, + r#"{"value":"YQ==","tags":["\u0000"]}"#, + r#"{"value":"YQ==","policy":{"unknown":true}}"#, + ] { + assert_eq!( + request(&server, "PUT", "/cache/ns/reports/key", body, None).status, + 400, + "{body}" + ); + } + for path in [ + "/cache/ns/bad%00/key", + "/cache/ns/reports/%FF", + "/cache/ns/reports/%0A", + ] { + assert_eq!( + request(&server, "PUT", path, r#"{"value":"YQ=="}"#, None).status, + 400 + ); + } + let large = ::serde_json::json!({ "value": STANDARD.encode(vec![0; VALUE_BYTES_MAX + 1]) }) + .to_string(); + assert_eq!( + request(&server, "PUT", "/cache/ns/reports/large", &large, None).status, + 413 + ); + assert_eq!( + request(&server, "DELETE", "/cache/ns/reports", "", None).status, + 400 + ); + for prefix in ["%FF", "%00", "%GG"] { + assert_eq!( + request( + &server, + "DELETE", + &format!("/cache/ns/reports?prefix={prefix}"), + "", + None, + ) + .status, + 400 + ); + } + assert!( + value(request(&server, "GET", "/cache/ns/reports/key", "", None))["value"].is_null() + ); + } + + #[test] + fn native_cache_http_scopes_tenants_and_honors_roles_and_iam_denies() { + let store = Arc::new(AuthStore::new(AuthConfig { + enabled: true, + require_auth: true, + ..AuthConfig::default() + })); + let mut keys = Vec::new(); + for (tenant, role) in [ + (Some("acme"), Role::Write), + (Some("globex"), Role::Write), + (None, Role::Write), + (Some("acme"), Role::Read), + ] { + let username = if role == Role::Read { + "reader" + } else { + "writer" + }; + store + .create_user_in_tenant(tenant, username, "password", role) + .expect("test user"); + keys.push( + store + .create_api_key_in_tenant(tenant, username, "test", role) + .expect("test key") + .key, + ); + } + let server = server().with_auth(Arc::clone(&store)); + assert_eq!( + request(&server, "GET", "/cache/ns/reports/key", "", None).status, + 401 + ); + for (key, bytes) in keys.iter().take(3).zip([ + b"acme".as_slice(), + b"globex".as_slice(), + b"platform".as_slice(), + ]) { + put(&server, "reports", "key", bytes, Some(key)); + } + assert_eq!( + value(request( + &server, + "GET", + "/cache/ns/reports/key", + "", + Some(&keys[0]) + ))["value"], + STANDARD.encode(b"acme") + ); + assert_eq!( + value(request( + &server, + "GET", + "/cache/ns/reports/key", + "", + Some(&keys[1]) + ))["value"], + STANDARD.encode(b"globex") + ); + assert_eq!( + value(request( + &server, + "GET", + "/cache/ns/reports/key", + "", + Some(&keys[3]) + ))["value"], + STANDARD.encode(b"acme") + ); + assert!(matches!( + request( + &server, + "PUT", + "/cache/ns/reports/key", + r#"{"value":"YQ=="}"#, + Some(&keys[3]) + ) + .status, + 401 | 403 + )); + value(request( + &server, + "POST", + "/cache/ns/reports/flush", + "", + Some(&keys[0]), + )); + assert_eq!( + value(request( + &server, + "GET", + "/cache/ns/reports/key", + "", + Some(&keys[1]) + ))["value"], + STANDARD.encode(b"globex") + ); + let policy = Policy::from_json_str(r#"{"id":"cache-deny","version":1,"statements":[{"effect":"deny","actions":["cache:read","cache:write","cache:invalidate"],"resources":["cache:reports"]}]}"#).expect("cache policy"); + store.put_policy(policy).expect("put policy"); + store + .attach_policy( + PrincipalRef::User(UserId::from_parts(Some("globex"), "writer")), + "cache-deny", + ) + .expect("attach policy"); + for (method, path, body) in [ + ("GET", "/cache/ns/reports/key", ""), + ("PUT", "/cache/ns/reports/key", r#"{"value":"YQ=="}"#), + ("POST", "/cache/ns/reports/flush", ""), + ] { + assert_eq!( + request(&server, method, path, body, Some(&keys[1])).status, + 403 + ); + } + } +} diff --git a/crates/reddb-server/src/server/routes/cache.route.rs b/crates/reddb-server/src/server/routes/cache.route.rs new file mode 100644 index 000000000..20274d05f --- /dev/null +++ b/crates/reddb-server/src/server/routes/cache.route.rs @@ -0,0 +1,97 @@ +use crate::server::route_catalog::{ + RouteAlias, RouteAudience, RouteAuth, RouteEntry, RouteGroupDefaults, RouteMethod, + RouteRegistry, RouteRequest, RouteStability, +}; +use crate::server::routes::common::{PUBLIC_SURFACES, STANDARD_MIDDLEWARE}; +use crate::server::{HttpResponse, RedDBServer}; + +const CACHE_USER: RouteGroupDefaults = RouteGroupDefaults { + family: "cache", + audience: RouteAudience::Client, + auth: RouteAuth::UserRequired, + surfaces: PUBLIC_SURFACES, + stability: RouteStability::Stable, + middlewares: STANDARD_MIDDLEWARE, +}; + +macro_rules! cache_aliases { + ($method:expr, $pattern:expr) => { + &[RouteAlias::canonical( + $method, + $pattern, + "canonical v1 cache path", + )] + }; +} + +const CACHE_ROUTES: &[RouteEntry] = &[ + RouteEntry::with_aliases( + "cache.get", + RouteMethod::Get, + "/cache/ns/:namespace/:key", + cache_aliases!(RouteMethod::Get, "/v1/cache/ns/:namespace/:key"), + get, + ), + RouteEntry::with_aliases( + "cache.put", + RouteMethod::Put, + "/cache/ns/:namespace/:key", + cache_aliases!(RouteMethod::Put, "/v1/cache/ns/:namespace/:key"), + put, + ), + RouteEntry::with_aliases( + "cache.exists", + RouteMethod::Get, + "/cache/ns/:namespace/:key/exists", + cache_aliases!(RouteMethod::Get, "/v1/cache/ns/:namespace/:key/exists"), + exists, + ), + RouteEntry::with_aliases( + "cache.invalidate", + RouteMethod::Delete, + "/cache/ns/:namespace/:key", + cache_aliases!(RouteMethod::Delete, "/v1/cache/ns/:namespace/:key"), + invalidate, + ), + RouteEntry::with_aliases( + "cache.invalidate_prefix", + RouteMethod::Delete, + "/cache/ns/:namespace", + cache_aliases!(RouteMethod::Delete, "/v1/cache/ns/:namespace"), + invalidate_prefix, + ), + RouteEntry::with_aliases( + "cache.invalidate_tags", + RouteMethod::Post, + "/cache/ns/:namespace/invalidate-tags", + cache_aliases!(RouteMethod::Post, "/v1/cache/ns/:namespace/invalidate-tags"), + invalidate_tags, + ), + RouteEntry::with_aliases( + "cache.flush_namespace", + RouteMethod::Post, + "/cache/ns/:namespace/flush", + cache_aliases!(RouteMethod::Post, "/v1/cache/ns/:namespace/flush"), + flush, + ), +]; + +pub(crate) fn register(registry: &mut RouteRegistry) { + registry.routes(CACHE_USER, CACHE_ROUTES); +} + +macro_rules! cache_handler { + ($name:ident, $operation:literal, $capability:literal) => { + fn $name(server: &RedDBServer, request: &RouteRequest<'_>) -> Option { + Some(server.handle_cache(request, $operation, $capability)) + } + }; +} + +cache_handler!(get, "get", "cache:read"); +cache_handler!(put, "put", "cache:write"); +cache_handler!(exists, "exists", "cache:read"); +cache_handler!(invalidate, "invalidate", "cache:invalidate"); +cache_handler!(invalidate_prefix, "invalidate_prefix", "cache:invalidate"); +cache_handler!(invalidate_tags, "invalidate_tags", "cache:invalidate"); +cache_handler!(flush, "flush", "cache:invalidate"); diff --git a/docs/api/cache.md b/docs/api/cache.md new file mode 100644 index 000000000..693dfbb37 --- /dev/null +++ b/docs/api/cache.md @@ -0,0 +1,79 @@ +# Remote Blob Cache + +The native Blob Cache is available to remote applications over HTTP. It stores +opaque bytes by namespace and key, independently of SQL collections and the +runtime's internal result-cache namespace. Reads return bytes or a cache miss. + +```js +import { connect } from '@reddb-io/client' + +const db = await connect('https://database.example', { + auth: { token: process.env.REDDB_TOKEN }, +}) +const namespace = 'stg:organization-id:bigquery' +const key = 'daily-usage:2026-10:query-and-parameters-hash' +const payload = await db.cache.get(namespace, key) +if (payload !== null) { + console.log(JSON.parse(new TextDecoder().decode(payload))) +} else { + const result = await queryBigQuery() + await db.cache.put(namespace, key, JSON.stringify(result), { + ttl_ms: 600_000, + tags: ['daily-usage'], + }) +} +await db.close() +``` + +Authorize the application user and organization before looking up cached +results. Include the environment, organization, query version and parameters +in the key; add user identity when the result is personalized. External +BigQuery changes do not trigger RedDB table dependency invalidation: choose a +TTL or explicitly refresh/invalidate the derived result. + +| SDK operation | HTTP endpoint | +| --- | --- | +| `get(namespace, key)` | `GET /cache/ns/:namespace/:key` | +| `put(namespace, key, bytes, options)` | `PUT /cache/ns/:namespace/:key` | +| `exists(namespace, key)` | `GET /cache/ns/:namespace/:key/exists` | +| `invalidate(namespace, key)` | `DELETE /cache/ns/:namespace/:key` | +| `invalidatePrefix(namespace, prefix)` | `DELETE /cache/ns/:namespace?prefix=...` | +| `invalidateTags(namespace, tags)` | `POST /cache/ns/:namespace/invalidate-tags` | +| `flushNamespace(namespace)` | `POST /cache/ns/:namespace/flush` | + +Path components are percent-encoded by the SDK. Arbitrary key names, including +`tags`, `flush` and `invalidate-tags`, remain usable. JSON bodies carry binary +values as standard base64; misses return `{ "value": null }` with HTTP 200. +Responses use `Cache-Control: no-store`. + +Every route also has a canonical `/v1/cache/ns/...` alias with the same +authentication, namespace partition and behavior. + +The authenticated credential determines the tenant partition; a tenant header +cannot select another partition. With IAM enabled, authorize `cache:read`, +`cache:write` and `cache:invalidate` against `cache:` resources. +Read-only credentials cannot write or invalidate values. Application flushes +do not require or use an operator admin token. + +`ttl_ms` is a hard lifetime in milliseconds, with no time-based expiry when +omitted. The optional `policy` accepts `ttl_ms`, `expires_at_unix_ms`, +`max_blob_bytes`, `l1_admission` (`auto`, `always`, `never`), `priority` (0–255) +and monotonically increasing `version`. Conflicting TTL fields are rejected. +Idle, stale-serving and jitter policies are not exposed by these HTTP methods. + +Remote values are limited to 1 MiB, keys to 4096 UTF-8 bytes, namespaces and +tags to 256 UTF-8 bytes, and each write/invalidation to 64 tags. Native cache +capacity limits also apply. Malformed requests return 400; oversized values +return 413, version conflicts 409, namespace exhaustion 429 and storage +unavailability 503. + +The cache and its invalidations are local to the addressed server. Use a +single cache endpoint or explicit cache routing when deploying multiple nodes; +these routes do not add cluster-wide cache coherence. Native L1/L2 admission, +eviction and persistence behavior continues to apply. + +Verify the real remote driver against a built server: + +```sh +RED_BIN=/absolute/path/to/red node scripts/test-remote-cache.mjs +``` diff --git a/docs/data-models/cache.md b/docs/data-models/cache.md index fb485931e..6c5f4b82f 100644 --- a/docs/data-models/cache.md +++ b/docs/data-models/cache.md @@ -3,7 +3,7 @@ RedDB can support cache workloads in two different ways: - **Key-Value** stores queryable JSON-compatible values as collection records. -- **Blob Cache** is the proposed native cache module for arbitrary bytes, rich +- **Blob Cache** is the native cache module for arbitrary bytes, rich TTL, fast existence checks, and explicit invalidation. Use Blob Cache when the value is primarily a cached artifact, not a record you @@ -15,7 +15,7 @@ want to query with SQL. |:---------|:-----------| | Feature flags, config, small JSON session state | Key-Value | | Cached HTTP responses, rendered pages, model outputs, binary blobs | Blob Cache | -| SQL `SELECT` result reuse | Runtime result cache, eventually backed by Blob Cache | +| SQL `SELECT` result reuse | Runtime result cache with optional Blob Cache backend | | Embedding-similarity answer reuse | Semantic cache | | Durable job flow | Queues | @@ -41,9 +41,9 @@ Blob Cache Interface durable metadata + blob bytes ``` -L1 is process-local and optimized for hot hits. L2 is stored in the RedDB -database file so cached blobs can survive restart and can be managed by the same -backup, replication, and observability story as the rest of the engine. +L1 is process-local and optimized for hot hits. When configured, L2 uses native +cache storage beside the database. Cache state and invalidation are node-local; +the HTTP API does not add replication or cluster-wide cache coherence. ## TTL Policy @@ -64,6 +64,11 @@ seconds" setting. Hard expiry is authoritative. A caller only receives stale data when it asks for stale serving and the entry is still inside the stale window. +This table describes native engine policy. The remote HTTP contract exposes +hard TTL, absolute expiry, admission, priority and version controls; it does not +expose idle TTL, stale serving, jitter or dependency registration. See +[Remote Blob Cache](/api/cache.md) for the exact application contract. + ## Existence Checks Blob Cache should make "does this key exist?" cheap without trusting @@ -114,6 +119,8 @@ to avoid running a separate Redis instance for exact-key cached artifacts. ## Current Status -Blob Cache is a proposed module, tracked by -[ADR 0006](/adr/0006-tiered-blob-cache.md). The current shipped cache layers are -documented in [SIEVE Cache](/engine/cache.md). +The native module is accessible through HTTP `/cache/ns/*` and `db.cache.*` +using the remote JavaScript client's HTTP transport. It is a supporting module, +not a SQL `CREATE CACHE` collection. See [Remote Blob Cache](/api/cache.md) +for usage, including a ten-minute TTL example for BigQuery results. +Other engine cache layers are documented in [SIEVE Cache](/engine/cache.md). diff --git a/docs/data-models/overview.md b/docs/data-models/overview.md index 13927c99e..0c53c4fba 100644 --- a/docs/data-models/overview.md +++ b/docs/data-models/overview.md @@ -87,7 +87,7 @@ These are the main structures most users mean when they ask "what can I build in | Tables & Rows | `CREATE TABLE`, `INSERT INTO users (...)` | `SELECT`, `UPDATE`, `DELETE` | Structured records with typed fields | Native `TableRow` | | Documents | `INSERT INTO logs DOCUMENT VALUES ({...})` | `SELECT`, `FROM ANY`, field projections | Flexible JSON payloads | User-facing model over the unified collection path | | Key-Value | `INSERT INTO config KV (key, value) VALUES (...)` or KV HTTP API | KV API, `SELECT key, value FROM config` | Config, sessions, flags, cache-like lookups | User-facing model over the unified collection path | -| Cache | Internal Blob Cache Interface first; public API later | Exact key lookup, existence check, invalidation APIs | Arbitrary cached blobs with rich TTL and durable L2 | Proposed supporting engine structure | +| Cache | HTTP `/cache/ns/*`, SDK `db.cache.*` over HTTP | Exact key lookup, existence check, invalidation APIs | Arbitrary cached blobs with TTL and explicit invalidation | Supporting engine structure; see [Remote Blob Cache](/api/cache.md) | | Graphs | `INSERT INTO network NODE ...`, `INSERT INTO network EDGE (label, from_rid, to_rid) ...` | `MATCH`, `GRAPH ...`, `PATH ...` | Relationships, traversals, analytics | Native `GraphNode` and `GraphEdge` | | Vectors | `INSERT INTO embeddings VECTOR (...)` | `VECTOR SEARCH`, `HYBRID ... VECTOR SEARCH` | Embeddings, semantic retrieval, RAG | Native `Vector` | | Metrics | `CREATE METRICS`, Prometheus `remote_write` | Prometheus query API subset, native metrics plans, SQL follow-up | SRE dashboards, Prometheus/Grafana backend, retained rollups | Operational telemetry model over time-series internals | diff --git a/docs/engine/cache.md b/docs/engine/cache.md index f4893d029..ff5d32d86 100644 --- a/docs/engine/cache.md +++ b/docs/engine/cache.md @@ -146,14 +146,16 @@ The plan cache saves ~100us per query for repeated SQL patterns. It is especiall └──────────────────────────────────────────────────────┘ ``` -## Proposed Blob Cache +## Blob Cache -RedDB also has a proposed native **Blob Cache** module for Redis-adjacent cache +RedDB also has a native **Blob Cache** module for exact-key cache workloads. Unlike the page cache, result cache, and entity cache, Blob Cache is designed around arbitrary byte values, rich TTL policy, durable L2 storage, fast existence checks, and explicit invalidation by key, prefix, tag, dependency, or namespace generation. -See [Cache](/data-models/cache.md) and -[ADR 0006](/adr/0006-tiered-blob-cache.md) for the proposed Interface and -rollout plan. +Applications can access the native module through the HTTP data plane and +`db.cache.*` in the remote JavaScript client using HTTP. See +[Remote Blob Cache](/api/cache.md) for supported policies, tenant isolation, +limits and node-local invalidation behavior, and +[ADR 0006](/adr/0006-tiered-blob-cache.md) for the architecture. diff --git a/docs/reference/sql-1-0-x.md b/docs/reference/sql-1-0-x.md index 07b9010ba..8a7aeb68f 100644 --- a/docs/reference/sql-1-0-x.md +++ b/docs/reference/sql-1-0-x.md @@ -17,7 +17,7 @@ Lexer keywords: Top-level SQL dispatch: -`CHECKPOINT`, `AUTHOR`, `CHECKOUT`, `RESET`, `HARD`, `SOFT`, `MIXED`, `MERGE`, `CHERRY`, `PICK`, `PICK`, `REVERT`, `RESOLVE`, `CONFLICT`, `CONFLICT`, `USING`, `OURS`, `THEIRS`, `OURS`, `THEIRS`, `CHECKPOINT`, `CHECKOUT`, `RESET`, `MERGE`, `CHERRY`, `REVERT`, `RESOLVE`, `STORE`, `STORE`, `AT`, `LSN`, `LSN`, `FORK`, `FORK`, `OR`, `REPLACE`, `RETENTION`, `RETENTION`, `AS`, `AS`, `EVERY`, `EVERY`, `VIEW`, `USER`, `BRANCH`, `TAG`, `CONFIG`, `VAULT`, `METRICS`, `SLO`, `HYPERTABLE`, `HLL`, `SKETCH`, `FILTER`, `NODES`, `EDGES`, `VECTORS`, `MESSAGES`, `POINTS`, `DOCUMENTS`, `NOT`, `NULL`, `WITH`, `BY`, `MIGRATION`, `TABLE`, `GRAPH`, `VECTOR`, `DOCUMENT`, `KV`, `COLLECTION`, `INDEX`, `UNIQUE`, `METRIC`, `TIMESERIES`, `QUEUE`, `TREE`, `HLL`, `SKETCH`, `FILTER`, `SCHEMA`, `SEQUENCE`, `USER`, `BRANCH`, `TAG`, `MIGRATION`, `BRANCH`, `TAG`, `BRANCH`, `TAG`, `FORK`, `PROMOTE`, `SECRET`, `METRICS`, `TABLE`, `GRAPH`, `VECTOR`, `DOCUMENT`, `TIMESERIES`, `METRICS`, `KV`, `QUEUE`, `COLLECTION`, `MIGRATION`, `VIEW`, `FORK`, `BRANCH`, `TAG`, `CONFIG`, `VAULT`, `METRICS`, `HYPERTABLE`, `HLL`, `SKETCH`, `FILTER`, `TABLE`, `INDEX`, `TIMESERIES`, `QUEUE`, `TREE`, `HLL`, `SKETCH`, `FILTER`, `SCHEMA`, `SEQUENCE`, `BRANCH`, `TAG`, `USER`, `COLLECTION`, `GRANT`, `REVOKE`, `EVENTS`, `BACKFILL`, `STATUS`, `STATUS`, `SIMULATE`, `LINT`, `MIGRATE`, `POLICY`, `POLICY`, `RESOLVE`, `CONFIG`, `SECRET`, `TENANT`, `NULL`, `CONFIG`, `SECRET`, `KV`, `TENANT`, `APPLY`, `RESET`, `TENANT`, `DESCRIBE`, `DESC`, `SCRUB`, `BACKGROUND`, `BUDGET`, `SHOW`, `CREATE`, `TABLE`, `TABLE`, `CONFIG`, `JSON`, `COLLECTIONS`, `INCLUDING`, `INTERNAL`, `INTERNAL`, `TABLES`, `QUEUES`, `INCLUDING`, `INTERNAL`, `INTERNAL`, `BRANCHES`, `TAGS`, `VECTORS`, `DOCUMENTS`, `TIMESERIES`, `GRAPHS`, `CONFIGS`, `VAULTS`, `KV`, `KVS`, `SCHEMA`, `INDICES`, `INDEXES`, `POLICIES`, `FOR`, `ON`, `STATS`, `SAMPLE`, `SECRET`, `SECRETS`, `TENANT`, `CONFIG`, `SECRET`, `SECRETS`, `COLLECTIONS`, `TABLES`, `QUEUES`, `VECTORS`, `DOCUMENTS`, `TIMESERIES`, `GRAPHS`, `KV`, `SCHEMA`, `INDICES`, `INDEXES`, `SAMPLE`, `POLICIES`, `STATS`, `TENANT`, `EFFECTIVE`, `ISOLATION`, `READ`, `UNCOMMITTED`, `COMMITTED`, `UNCOMMITTED`, `COMMITTED`, `REPEATABLE`, `READ`, `READ`, `SNAPSHOT`, `SERIALIZABLE`, `READ`, `REPEATABLE`, `SNAPSHOT`, `SERIALIZABLE`, `MIGRATION`, `WITH`, `FORMAT`, `SELECT`, `FROM`, `INSERT`, `UPDATE`, `DELETE`, `EXPLAIN`, `CREATE`, `DROP`, `ALTER`, `SET`, `SHOW`, `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, `START`, `VACUUM`, `ANALYZE`, `COPY`, `REFRESH`, `DESCRIBE`, `DESC` +`CHECKPOINT`, `AUTHOR`, `CHECKOUT`, `RESET`, `HARD`, `SOFT`, `MIXED`, `MERGE`, `CHERRY`, `PICK`, `PICK`, `REVERT`, `RESOLVE`, `CONFLICT`, `CONFLICT`, `USING`, `OURS`, `THEIRS`, `OURS`, `THEIRS`, `CHECKPOINT`, `CHECKOUT`, `RESET`, `MERGE`, `CHERRY`, `REVERT`, `RESOLVE`, `STORE`, `STORE`, `AT`, `LSN`, `LSN`, `FORK`, `FORK`, `OR`, `REPLACE`, `RETENTION`, `RETENTION`, `AS`, `AS`, `EVERY`, `EVERY`, `VIEW`, `USER`, `BRANCH`, `TAG`, `CONFIG`, `VAULT`, `METRICS`, `SLO`, `HYPERTABLE`, `HLL`, `SKETCH`, `FILTER`, `VECTORS`, `NODES`, `EDGES`, `VECTORS`, `MESSAGES`, `POINTS`, `DOCUMENTS`, `NOT`, `NULL`, `WITH`, `BY`, `MIGRATION`, `TABLE`, `GRAPH`, `VECTOR`, `DOCUMENT`, `KV`, `COLLECTION`, `INDEX`, `UNIQUE`, `METRIC`, `TIMESERIES`, `QUEUE`, `TREE`, `HLL`, `SKETCH`, `FILTER`, `SCHEMA`, `SEQUENCE`, `USER`, `BRANCH`, `TAG`, `MIGRATION`, `BRANCH`, `TAG`, `BRANCH`, `TAG`, `FORK`, `PROMOTE`, `SECRET`, `METRICS`, `TABLE`, `GRAPH`, `VECTOR`, `DOCUMENT`, `TIMESERIES`, `METRICS`, `KV`, `QUEUE`, `COLLECTION`, `MIGRATION`, `VIEW`, `FORK`, `BRANCH`, `TAG`, `CONFIG`, `VAULT`, `METRICS`, `HYPERTABLE`, `HLL`, `SKETCH`, `FILTER`, `TABLE`, `INDEX`, `TIMESERIES`, `QUEUE`, `TREE`, `HLL`, `SKETCH`, `FILTER`, `SCHEMA`, `SEQUENCE`, `BRANCH`, `TAG`, `USER`, `COLLECTION`, `GRANT`, `REVOKE`, `EVENTS`, `BACKFILL`, `STATUS`, `STATUS`, `SIMULATE`, `LINT`, `MIGRATE`, `POLICY`, `POLICY`, `RESOLVE`, `CONFIG`, `SECRET`, `TENANT`, `NULL`, `CONFIG`, `SECRET`, `KV`, `TENANT`, `APPLY`, `RESET`, `TENANT`, `DESCRIBE`, `DESC`, `SCRUB`, `BACKGROUND`, `BUDGET`, `SHOW`, `CREATE`, `TABLE`, `TABLE`, `CONFIG`, `JSON`, `COLLECTIONS`, `INCLUDING`, `INTERNAL`, `INTERNAL`, `TABLES`, `QUEUES`, `INCLUDING`, `INTERNAL`, `INTERNAL`, `BRANCHES`, `TAGS`, `VECTORS`, `DOCUMENTS`, `TIMESERIES`, `GRAPHS`, `CONFIGS`, `VAULTS`, `KV`, `KVS`, `SCHEMA`, `INDICES`, `INDEXES`, `POLICIES`, `FOR`, `ON`, `STATS`, `SAMPLE`, `SECRET`, `SECRETS`, `TENANT`, `CONFIG`, `SECRET`, `SECRETS`, `COLLECTIONS`, `TABLES`, `QUEUES`, `VECTORS`, `DOCUMENTS`, `TIMESERIES`, `GRAPHS`, `KV`, `SCHEMA`, `INDICES`, `INDEXES`, `SAMPLE`, `POLICIES`, `STATS`, `TENANT`, `EFFECTIVE`, `ISOLATION`, `READ`, `UNCOMMITTED`, `COMMITTED`, `UNCOMMITTED`, `COMMITTED`, `REPEATABLE`, `READ`, `READ`, `SNAPSHOT`, `SERIALIZABLE`, `READ`, `REPEATABLE`, `SNAPSHOT`, `SERIALIZABLE`, `MIGRATION`, `WITH`, `FORMAT`, `SELECT`, `FROM`, `INSERT`, `UPDATE`, `DELETE`, `EXPLAIN`, `CREATE`, `DROP`, `ALTER`, `SET`, `SHOW`, `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, `START`, `VACUUM`, `ANALYZE`, `COPY`, `REFRESH`, `DESCRIBE`, `DESC` ## Query Mode Detection @@ -195,7 +195,7 @@ columns or top-level document, KV, node, or edge properties named `rid`, | `LIST CONFIG` / `WATCH CONFIG` / `INVALIDATE CONFIG` | `LIST CONFIG durability` | supported | | `CREATE VAULT` / `SET SECRET` / `SHOW SECRET` / `DELETE SECRET` | `SET SECRET openai.api_key = 'sk-...'` | supported | | `UNSEAL VAULT` / `WATCH VAULT` / `LIST VAULT` | `UNSEAL VAULT red.vault` | partial | -| Blob `CACHE` API | HTTP `/cache/*`, SDK `db.cache.*` | supported over HTTP/gRPC clients; unsupported on embedded stdio | +| Blob `CACHE` API | HTTP `/cache/*`, SDK `db.cache.*` | supported over HTTP; unsupported on RedWire, gRPC and embedded stdio; see [Remote Blob Cache](/api/cache.md) | ## QUEUE diff --git a/drivers/js-client/index.browser.d.ts b/drivers/js-client/index.browser.d.ts index d9113750f..ad2002239 100644 --- a/drivers/js-client/index.browser.d.ts +++ b/drivers/js-client/index.browser.d.ts @@ -101,9 +101,12 @@ export interface CachePutOptions { ttl_ms?: number tags?: string[] policy?: { - idle_evict_ms?: number - stale_while_revalidate_ms?: number - jitter_ms?: number + ttl_ms?: number + expires_at_unix_ms?: number + max_blob_bytes?: number + l1_admission?: 'auto' | 'always' | 'never' + priority?: number + version?: number } } diff --git a/drivers/js-client/index.d.ts b/drivers/js-client/index.d.ts index d015f68b6..1578456a1 100644 --- a/drivers/js-client/index.d.ts +++ b/drivers/js-client/index.d.ts @@ -97,9 +97,12 @@ export interface CachePutOptions { ttl_ms?: number tags?: string[] policy?: { - idle_evict_ms?: number - stale_while_revalidate_ms?: number - jitter_ms?: number + ttl_ms?: number + expires_at_unix_ms?: number + max_blob_bytes?: number + l1_admission?: 'auto' | 'always' | 'never' + priority?: number + version?: number } } diff --git a/drivers/js-client/src/cache.js b/drivers/js-client/src/cache.js index 81eb836ac..c305eb294 100644 --- a/drivers/js-client/src/cache.js +++ b/drivers/js-client/src/cache.js @@ -2,9 +2,9 @@ * Cache client — exposes cache.{get,put,exists,invalidate,invalidatePrefix, * invalidateTags,flushNamespace} via the underlying HTTP transport. * - * NOTE: These methods require server-side HTTP endpoints under /cache/ns/*. - * flushNamespace routes to the existing POST /admin/blob_cache/flush_namespace. - * All others target endpoints planned for a future server release. + * The HTTP data-plane endpoints under /cache/ns/* authorize the caller's + * tenant and cache namespace. Invalidation is local to the addressed server. + * Use the HTTP transport for remote cache operations. * * Values are base64-encoded in transit so binary payloads survive JSON. */ @@ -93,7 +93,7 @@ export class CacheClient { /** * Remove all entries in a namespace. - * Routes to POST /admin/blob_cache/flush_namespace (live endpoint). + * Uses the caller-scoped POST /cache/ns/:namespace/flush endpoint. * @param {string} namespace * @returns {Promise} */ diff --git a/drivers/js-client/src/http.js b/drivers/js-client/src/http.js index c354c2637..dd2902d8f 100644 --- a/drivers/js-client/src/http.js +++ b/drivers/js-client/src/http.js @@ -27,8 +27,8 @@ * cache.exists → GET /cache/ns/:ns/:key/exists * cache.invalidate → DELETE /cache/ns/:ns/:key * cache.invalidate_prefix → DELETE /cache/ns/:ns?prefix=... - * cache.invalidate_tags → DELETE /cache/ns/:ns/tags (body: {tags}) - * cache.flush_namespace → POST /admin/blob_cache/flush_namespace + * cache.invalidate_tags → POST /cache/ns/:ns/invalidate-tags (body: {tags}) + * cache.flush_namespace → POST /cache/ns/:ns/flush * * Auth: every request after construction carries `Authorization: * Bearer ` (when set). `setToken(token)` updates it in @@ -395,11 +395,11 @@ const ROUTES = { init: { method: 'DELETE' }, }), 'cache.invalidate_tags': (base, { namespace, tags }) => ({ - url: `${base}/cache/ns/${encodeURIComponent(namespace)}/tags`, - init: { method: 'DELETE', body: JSON.stringify({ tags }) }, + url: `${base}/cache/ns/${encodeURIComponent(namespace)}/invalidate-tags`, + init: { method: 'POST', body: JSON.stringify({ tags }) }, }), 'cache.flush_namespace': (base, { namespace }) => ({ - url: `${base}/admin/blob_cache/flush_namespace`, - init: { method: 'POST', body: JSON.stringify({ namespace }) }, + url: `${base}/cache/ns/${encodeURIComponent(namespace)}/flush`, + init: { method: 'POST' }, }), } diff --git a/drivers/js-client/test/cache.test.mjs b/drivers/js-client/test/cache.test.mjs index 10f14f2d7..0b87bbb03 100644 --- a/drivers/js-client/test/cache.test.mjs +++ b/drivers/js-client/test/cache.test.mjs @@ -206,11 +206,11 @@ test('cache.invalidatePrefix sends DELETE with prefix query param', async () => } }) -test('cache.invalidateTags sends DELETE with tags body', async () => { +test('cache.invalidateTags sends POST without reserving a cache key', async () => { let captured = null const stub = await startMockServer({ 'GET /health': () => ({ ok: true, version: 'mock' }), - 'DELETE /cache/ns/ns1/tags': (body) => { + 'POST /cache/ns/ns1/invalidate-tags': (body) => { captured = body return { removed: 5 } }, @@ -225,11 +225,11 @@ test('cache.invalidateTags sends DELETE with tags body', async () => { } }) -test('cache.flushNamespace POSTs to /admin/blob_cache/flush_namespace', async () => { +test('cache.flushNamespace uses the caller-scoped namespace endpoint', async () => { let captured = null const stub = await startMockServer({ 'GET /health': () => ({ ok: true, version: 'mock' }), - 'POST /admin/blob_cache/flush_namespace': (body) => { + 'POST /cache/ns/ns1/flush': (body) => { captured = body return { ok: true } }, @@ -237,7 +237,7 @@ test('cache.flushNamespace POSTs to /admin/blob_cache/flush_namespace', async () try { const db = await connect(stub.baseUrl) await db.cache.flushNamespace('ns1') - assert.equal(captured.namespace, 'ns1') + assert.deepEqual(captured, {}) } finally { await stub.close() } diff --git a/drivers/js/index.d.ts b/drivers/js/index.d.ts index c0bb99603..516b4acd7 100644 --- a/drivers/js/index.d.ts +++ b/drivers/js/index.d.ts @@ -167,15 +167,18 @@ export class RedDBError extends Error { /** Options for cache.put(). Maps to Rust BlobCachePolicy fields. */ export interface CachePutOptions { - /** Entry TTL in milliseconds. Omit to use the namespace default. */ + /** Hard TTL in milliseconds. Omit for no time-based expiry. */ ttl_ms?: number /** Tags for group invalidation via cache.invalidateTags(). */ tags?: string[] - /** Extended TTL policy (idle eviction, stale-while-revalidate, jitter). */ + /** Native hard-expiry and admission policy for remote HTTP cache entries. */ policy?: { - idle_evict_ms?: number - stale_while_revalidate_ms?: number - jitter_ms?: number + ttl_ms?: number + expires_at_unix_ms?: number + max_blob_bytes?: number + l1_admission?: 'auto' | 'always' | 'never' + priority?: number + version?: number } } @@ -215,7 +218,7 @@ export class CacheClient { invalidatePrefix(namespace: string, prefix: string): Promise /** Remove all entries tagged with any of the given tags. Returns count removed. */ invalidateTags(namespace: string, tags: string[]): Promise - /** Remove all entries in a namespace (routes to POST /admin/blob_cache/flush_namespace). */ + /** Remove entries through the caller-scoped POST /cache/ns/:namespace/flush endpoint. */ flushNamespace(namespace: string): Promise } diff --git a/drivers/js/src/cache.js b/drivers/js/src/cache.js index a185a0a28..6c58ea3aa 100644 --- a/drivers/js/src/cache.js +++ b/drivers/js/src/cache.js @@ -2,13 +2,9 @@ * Cache client — exposes cache.{get,put,exists,invalidate,invalidatePrefix, * invalidateTags,flushNamespace} via the underlying HTTP transport. * - * NOTE: These methods require server-side HTTP endpoints under /cache/ns/*. - * flushNamespace routes to the existing POST /admin/blob_cache/flush_namespace. - * All others target endpoints planned for a future server release. - * - * Requires an HTTP or gRPC transport. On embedded (stdio JSON-RPC), every - * method throws `UNSUPPORTED_TRANSPORT` before issuing the RPC call — - * the engine's stdio handler doesn't implement `cache.*`. + * The HTTP data-plane endpoints under /cache/ns/* authorize the caller's + * tenant and cache namespace. Invalidation is local to the addressed server. + * Use the HTTP transport for remote cache operations. * * Values are base64-encoded in transit so binary payloads survive JSON. */ @@ -123,7 +119,7 @@ export class CacheClient { /** * Remove all entries in a namespace. - * Routes to POST /admin/blob_cache/flush_namespace (live endpoint). + * Uses the caller-scoped POST /cache/ns/:namespace/flush endpoint. * @param {string} namespace * @returns {Promise} */ diff --git a/drivers/js/src/http.js b/drivers/js/src/http.js index d622b6b5c..5c8dbe57e 100644 --- a/drivers/js/src/http.js +++ b/drivers/js/src/http.js @@ -27,8 +27,8 @@ * cache.exists → GET /cache/ns/:ns/:key/exists * cache.invalidate → DELETE /cache/ns/:ns/:key * cache.invalidate_prefix → DELETE /cache/ns/:ns?prefix=... - * cache.invalidate_tags → DELETE /cache/ns/:ns/tags (body: {tags}) - * cache.flush_namespace → POST /admin/blob_cache/flush_namespace + * cache.invalidate_tags → POST /cache/ns/:ns/invalidate-tags (body: {tags}) + * cache.flush_namespace → POST /cache/ns/:ns/flush * * Auth: every request after construction carries `Authorization: * Bearer ` (when set). `setToken(token)` updates it in @@ -195,11 +195,11 @@ const ROUTES = { init: { method: 'DELETE' }, }), 'cache.invalidate_tags': (base, { namespace, tags }) => ({ - url: `${base}/cache/ns/${encodeURIComponent(namespace)}/tags`, - init: { method: 'DELETE', body: JSON.stringify({ tags }) }, + url: `${base}/cache/ns/${encodeURIComponent(namespace)}/invalidate-tags`, + init: { method: 'POST', body: JSON.stringify({ tags }) }, }), 'cache.flush_namespace': (base, { namespace }) => ({ - url: `${base}/admin/blob_cache/flush_namespace`, - init: { method: 'POST', body: JSON.stringify({ namespace }) }, + url: `${base}/cache/ns/${encodeURIComponent(namespace)}/flush`, + init: { method: 'POST' }, }), } diff --git a/drivers/node/Cargo.lock b/drivers/node/Cargo.lock index 10748ba28..a46a449f7 100644 --- a/drivers/node/Cargo.lock +++ b/drivers/node/Cargo.lock @@ -2490,6 +2490,7 @@ dependencies = [ "aes-gcm", "argon2", "axum", + "base64 0.22.1", "blake3", "bytes", "crc32fast", @@ -2752,9 +2753,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", diff --git a/drivers/python/Cargo.lock b/drivers/python/Cargo.lock index b1e0a9a1d..dcd744ed2 100644 --- a/drivers/python/Cargo.lock +++ b/drivers/python/Cargo.lock @@ -2162,9 +2162,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "bytes", "getrandom 0.4.3", @@ -2435,6 +2435,7 @@ dependencies = [ "aes-gcm", "argon2", "axum", + "base64", "blake3", "bytes", "crc32fast", @@ -2697,9 +2698,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", diff --git a/scripts/test-remote-cache.mjs b/scripts/test-remote-cache.mjs new file mode 100644 index 000000000..9d1fec0b1 --- /dev/null +++ b/scripts/test-remote-cache.mjs @@ -0,0 +1,82 @@ +// Exercise the shipped JavaScript remote client against a real RedDB server. +// RED_BIN is mandatory: never report a mock or skipped fixture as live coverage. +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdtemp, writeFile } from 'node:fs/promises' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { connect } from '../drivers/js-client/src/index.js' + +const binary = process.env.RED_BIN +assert.ok(binary, 'RED_BIN must identify the built RedDB server') +const directory = await mkdtemp(join(tmpdir(), 'reddb-remote-cache-')) +const reservation = createServer() +reservation.listen(0, '127.0.0.1'); await once(reservation, 'listening') +const port = reservation.address().port +await new Promise(resolve => reservation.close(resolve)) +const origin = `http://127.0.0.1:${port}` +const server = spawn(binary, ['server', '--path', join(directory, 'cache.rdb'), '--http-bind', `127.0.0.1:${port}`], { + stdio: ['ignore', 'pipe', 'pipe'], +}) +let logs = '', db +let spawnError +server.on('error', error => { spawnError = error }) +server.stdout.on('data', text => { logs += text }); server.stderr.on('data', text => { logs += text }) +try { + let ready = false + for (let attempt = 0; attempt < 150; attempt++) { + if (spawnError) throw spawnError + if (server.exitCode !== null) throw new Error('RedDB server exited before readiness') + try { + if ((await fetch(origin + '/health', { signal: AbortSignal.timeout(1000) })).ok) { + ready = true + break + } + } catch {} + await new Promise(resolve => setTimeout(resolve, 100)) + } + assert.ok(ready, 'RedDB server must become healthy before cache verification') + db = await connect(origin) + const cache = db.cache + const namespace = 'bigquery:organization/☃' + const key = 'daily:2026-10?filters=all/a%20b' + const payload = Buffer.from(JSON.stringify({ days: [{ day: '2026-10-14', tokens: 123 }] })) + assert.equal(await cache.get(namespace, key), null) + await cache.put(namespace, key, payload, { ttl_ms: 600_000, tags: ['organization:fixture', 'daily'] }) + const canonical = await fetch(`${origin}/v1/cache/ns/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`) + assert.equal(canonical.status, 200) + assert.equal((await canonical.json()).value, payload.toString('base64')) + assert.deepEqual(Buffer.from(await cache.get(namespace, key)), payload) + assert.equal(await cache.exists(namespace, key), 'present') + for (const name of ['tags', 'flush', 'invalidate-tags']) { + await cache.put(namespace, name, new Uint8Array([0, 255, 128, 10])) + assert.deepEqual([...await cache.get(namespace, name)], [0, 255, 128, 10]) + await cache.invalidate(namespace, name) + assert.equal(await cache.get(namespace, name), null) + } + await cache.put(namespace, 'short', 'expires', { ttl_ms: 1 }) + await new Promise(resolve => setTimeout(resolve, 20)) + assert.equal(await cache.get(namespace, 'short'), null) + assert.equal(await cache.invalidateTags(namespace, ['daily']), 1) + assert.equal(await cache.get(namespace, key), null) + await cache.put(namespace, 'prefix/a', 'a') + await cache.put(namespace, 'prefix/b', 'b') + await cache.put('other', 'prefix/a', 'independent') + assert.equal(await cache.invalidatePrefix(namespace, 'prefix/'), 2) + assert.equal(Buffer.from(await cache.get('other', 'prefix/a')).toString(), 'independent') + await cache.put(namespace, 'last', 'value') + await cache.flushNamespace(namespace) + assert.equal(await cache.get(namespace, 'last'), null) + console.log(JSON.stringify({ nativeCacheRemoteSdk: 'passed', tenMinuteTtl: 'accepted', binaryRoundtrip: true, expiration: true, allInvalidationMethods: true, artifacts: directory })) +} finally { + await db?.close() + if (server.exitCode === null && !spawnError) { + const stopped = once(server, 'close') + const timeout = setTimeout(() => server.kill('SIGKILL'), 5000) + server.kill('SIGTERM') + try { await stopped } finally { clearTimeout(timeout) } + } + await writeFile(join(directory, 'server.log'), logs) +}