Skip to main content

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}