distributed_cache/function.rs
1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! The distributed cache as one composable action function — route
18//! `v1.cache.redis` (Rust port of the Java `RedisCache`, design spec §4.4).
19//! Opt in with `redis.cache.enabled=true`; the function then registers and is
20//! reachable at Layer 1 (`po.request`), Layer 2 (an Event Script task with
21//! output data mapping) and Layer 3 (a `graph.task` node) — all three are just
22//! "call a route".
23//!
24//! **Contract.** The `action` header selects the operation ([`CacheAction`]);
25//! the payload rides in headers and the body:
26//!
27//! | action | headers | body (input) | result |
28//! |---|---|---|---|
29//! | `GET` | `key` | — | value bytes, or null (miss) |
30//! | `PUT` | `key`, `ttl`? | value bytes | `true` |
31//! | `DELETE` | `key` | — | count removed (integer) |
32//! | `PUT_IF_NOT_PRESENT` | `key`, `ttl`? | value bytes | boolean (stored?) |
33//! | `MGET` | — | list of keys | map key → bytes (misses omitted) |
34//! | `MPUT` | `ttl`? | map key → bytes | `true` |
35//! | `LIST_PUSH` | `key`, `ttl`? | value bytes | new length (integer) |
36//! | `LIST_POP` | `key` | — | value bytes, or null (empty) |
37//! | `LIST_LEN` | `key` | — | length (integer) |
38//!
39//! Values are opaque bytes — a MsgPack **binary** body
40//! (`EventEnvelope::set_raw_body(Value::Binary(..))`; the Java `byte[]`) — the
41//! caller owns serialisation; a string body is accepted as a UTF-8
42//! convenience. `ttl` is a duration string (`30s`/`5m`/`1h`, or bare
43//! seconds); when omitted a write uses `redis.cache.default.ttl`. Every worker
44//! instance (`redis.cache.instances`) shares the one connection held by
45//! [`runtime`](crate::runtime).
46
47use std::collections::HashMap;
48
49use async_trait::async_trait;
50use platform_core::{preload, AppError, ComposableFunction, EventEnvelope};
51use redis_connection::duration_seconds;
52use rmpv::Value;
53
54use crate::action::CacheAction;
55use crate::runtime;
56use crate::store::RedisCacheStore;
57
58/// The cache function's route.
59pub const CACHE_ROUTE: &str = "v1.cache.redis";
60
61const ACTION: &str = "action";
62const KEY: &str = "key";
63const TTL: &str = "ttl";
64
65/// `v1.cache.redis` — the cache as one action function. Registered by the
66/// preload inventory when the application links this crate and
67/// `redis.cache.enabled=true`; every call resolves the shared store lazily.
68#[preload(
69 route = "v1.cache.redis",
70 instances = 20,
71 env_instances = "redis.cache.instances"
72)]
73#[optional_service("redis.cache.enabled")]
74pub struct RedisCache;
75
76#[async_trait]
77impl ComposableFunction for RedisCache {
78 async fn handle_event(
79 &self,
80 headers: HashMap<String, String>,
81 input: EventEnvelope,
82 _instance: usize,
83 ) -> Result<EventEnvelope, AppError> {
84 let store = runtime::store().await?;
85 handle(&headers, input.body(), &store).await
86 }
87}
88
89/// The action dispatch over a given store (Java `RedisCache.handleEvent`) —
90/// public as the reuse/test seam the Java constructor-injected `Supplier`
91/// provides: drive the contract against any store, e.g. one built against an
92/// in-process server.
93pub async fn handle(
94 headers: &HashMap<String, String>,
95 input: &Value,
96 store: &RedisCacheStore,
97) -> Result<EventEnvelope, AppError> {
98 let action = CacheAction::from_header(headers.get(ACTION).map(String::as_str))?;
99 let key = headers.get(KEY).map(String::as_str);
100 let reply = match action {
101 CacheAction::Get => binary_or_nil(store.get(key).await?),
102 CacheAction::Put => {
103 store
104 .put(key, &as_bytes(input)?, ttl(headers, store)?)
105 .await?;
106 Value::Boolean(true)
107 }
108 CacheAction::Delete => Value::from(store.delete(key).await?),
109 CacheAction::PutIfNotPresent => Value::Boolean(
110 store
111 .put_if_absent(key, &as_bytes(input)?, ttl(headers, store)?)
112 .await?,
113 ),
114 CacheAction::Mget => Value::Map(
115 store
116 .mget(&as_key_list(input)?)
117 .await?
118 .into_iter()
119 .map(|(key, value)| (Value::from(key), Value::Binary(value)))
120 .collect(),
121 ),
122 CacheAction::Mput => {
123 store
124 .mput(&as_entry_map(input)?, ttl(headers, store)?)
125 .await?;
126 Value::Boolean(true)
127 }
128 CacheAction::ListPush => Value::from(
129 store
130 .list_push(key, &as_bytes(input)?, ttl(headers, store)?)
131 .await?,
132 ),
133 CacheAction::ListPop => binary_or_nil(store.list_pop(key).await?),
134 CacheAction::ListLen => Value::from(store.list_len(key).await?),
135 };
136 Ok(EventEnvelope::new().set_raw_body(reply))
137}
138
139fn binary_or_nil(value: Option<Vec<u8>>) -> Value {
140 value.map(Value::Binary).unwrap_or(Value::Nil)
141}
142
143/// The write-TTL: the `ttl` header (a duration string) when present, else the
144/// configured default. A header that does not parse to a positive duration is
145/// rejected (the Java engine's `getDurationInSeconds` would degrade it to a
146/// zero TTL the server rejects — the same outcome, said clearly).
147fn ttl(headers: &HashMap<String, String>, store: &RedisCacheStore) -> Result<u64, AppError> {
148 match headers.get(TTL).map(|text| text.trim()) {
149 Some(text) if !text.is_empty() => duration_seconds(text)
150 .filter(|seconds| *seconds > 0)
151 .ok_or_else(|| AppError::new(400, format!("Invalid 'ttl' - {text}"))),
152 _ => Ok(store.default_ttl_seconds()),
153 }
154}
155
156/// The value payload: binary as-is, or a string as UTF-8 (a convenience).
157fn as_bytes(input: &Value) -> Result<Vec<u8>, AppError> {
158 match input {
159 Value::Binary(bytes) => Ok(bytes.clone()),
160 Value::String(text) => Ok(text.as_bytes().to_vec()),
161 _ => Err(AppError::new(
162 400,
163 "A value (byte[] or String) is required in the body",
164 )),
165 }
166}
167
168/// The MGET key list: any list whose elements are read as their string form.
169fn as_key_list(input: &Value) -> Result<Vec<String>, AppError> {
170 match input {
171 Value::Array(items) => Ok(items
172 .iter()
173 .filter(|item| !item.is_nil())
174 .map(text_of)
175 .collect()),
176 _ => Err(AppError::new(
177 400,
178 "MGET requires a List of keys in the body",
179 )),
180 }
181}
182
183/// The MPUT entries: any map — string keys, binary/string values.
184fn as_entry_map(input: &Value) -> Result<Vec<(String, Vec<u8>)>, AppError> {
185 match input {
186 Value::Map(entries) => entries
187 .iter()
188 .map(|(key, value)| Ok((text_of(key), as_bytes(value)?)))
189 .collect(),
190 _ => Err(AppError::new(
191 400,
192 "MPUT requires a Map of key -> value in the body",
193 )),
194 }
195}
196
197fn text_of(value: &Value) -> String {
198 match value {
199 Value::String(text) => text.as_str().unwrap_or_default().to_string(),
200 other => other.to_string(),
201 }
202}