Skip to main content

pharos_native/runtime/
cache.rs

1//! Runtime-owned Redis cache orchestration.
2
3use std::path::Path;
4use std::sync::{OnceLock, RwLock};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::config::{self, ServeConfig};
8
9use super::cache_key;
10
11#[cfg(feature = "redis")]
12use pharos_runtime_integrations::redis::{
13    RedisConnection, RedisConnectionFactory, RedisError, RedisRuntimeConfig, RedisStatus,
14};
15#[cfg(feature = "redis")]
16use std::cell::RefCell;
17
18#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
19pub struct CachedSessionRecord {
20    pub user_id: i64,
21    pub expires_at_epoch: u64,
22}
23
24#[derive(Debug, Clone, Default)]
25struct RuntimeCacheState {
26    enabled: bool,
27    cache_sessions_enabled: bool,
28    cache_template_source_enabled: bool,
29    cache_route_match_enabled: bool,
30    cache_config_allowlist_enabled: bool,
31    cache_prefix: String,
32    status: String,
33    #[cfg(feature = "redis")]
34    next_connect_after_epoch: u64,
35    #[cfg(feature = "redis")]
36    current_backoff_seconds: u64,
37    route_match_generation: u64,
38    #[cfg(feature = "redis")]
39    factory: Option<RedisConnectionFactory>,
40}
41
42static RUNTIME_CACHE_STATE: OnceLock<RwLock<RuntimeCacheState>> = OnceLock::new();
43
44#[cfg(feature = "redis")]
45thread_local! {
46    static REDIS_CONNECTION: RefCell<Option<RedisConnection>> = const { RefCell::new(None) };
47}
48
49pub fn install_runtime_cache(config_record: &ServeConfig) {
50    let state_lock = RUNTIME_CACHE_STATE.get_or_init(|| RwLock::new(RuntimeCacheState::default()));
51    let mut state = match state_lock.write() {
52        Ok(state) => state,
53        Err(_) => return,
54    };
55
56    #[cfg(not(feature = "redis"))]
57    {
58        let _ = config_record;
59        *state = RuntimeCacheState {
60            enabled: false,
61            cache_sessions_enabled: false,
62            cache_template_source_enabled: false,
63            cache_route_match_enabled: false,
64            cache_config_allowlist_enabled: false,
65            cache_prefix: "pharos".to_owned(),
66            status: "disabled".to_owned(),
67            route_match_generation: 0,
68        };
69    }
70
71    #[cfg(feature = "redis")]
72    {
73        let runtime_config = runtime_config_from_serve_config(config_record);
74        let factory = RedisConnectionFactory::new(runtime_config.clone()).ok();
75        *state = RuntimeCacheState {
76            enabled: runtime_config.enabled && factory.is_some(),
77            cache_sessions_enabled: runtime_config.enabled && runtime_config.cache_sessions_enabled,
78            cache_template_source_enabled: runtime_config.enabled
79                && runtime_config.cache_template_source_enabled,
80            cache_route_match_enabled: runtime_config.enabled
81                && runtime_config.cache_route_match_enabled,
82            cache_config_allowlist_enabled: runtime_config.enabled
83                && runtime_config.cache_config_allowlist_enabled,
84            cache_prefix: runtime_config.cache_prefix.clone(),
85            status: if runtime_config.enabled && factory.is_some() {
86                RedisStatus::Connecting.as_str().to_owned()
87            } else {
88                RedisStatus::Disabled.as_str().to_owned()
89            },
90            next_connect_after_epoch: 0,
91            current_backoff_seconds: runtime_config.backoff_seconds.max(1),
92            route_match_generation: 0,
93            factory,
94        };
95        REDIS_CONNECTION.with(|cell| {
96            cell.borrow_mut().take();
97        });
98    }
99}
100
101pub fn redis_status() -> String {
102    cache_state_read(|state| state.status.clone()).unwrap_or_else(|| "disabled".to_owned())
103}
104
105pub fn probe_runtime_cache() {
106    #[cfg(feature = "redis")]
107    {
108        let _ = with_connection(|_, _| Ok(()));
109    }
110}
111
112pub fn session_cache_lookup(
113    app_id: &str,
114    session_token_hash: &str,
115    now_epoch_seconds: u64,
116) -> Option<CachedSessionRecord> {
117    if !session_cache_enabled() {
118        return None;
119    }
120    let key = prefix_runtime_key(cache_key::session_value(app_id, session_token_hash)?)?;
121    let text = get_value(&key)?;
122    let record = decode_session_record(&text)?;
123    (record.user_id > 0 && record.expires_at_epoch > now_epoch_seconds).then_some(record)
124}
125
126pub fn session_cache_store(
127    app_id: &str,
128    user_id: i64,
129    session_token_hash: &str,
130    expires_at_epoch: u64,
131) {
132    if !session_cache_enabled() || user_id <= 0 || expires_at_epoch <= now_epoch_seconds() {
133        return;
134    }
135    let Some(key) = cache_key::session_value(app_id, session_token_hash) else {
136        return;
137    };
138    let Some(index_key) = cache_key::session_user_index(app_id, user_id) else {
139        return;
140    };
141    let Some(key) = prefix_runtime_key(key) else {
142        return;
143    };
144    let Some(index_key) = prefix_runtime_key(index_key) else {
145        return;
146    };
147    let ttl = expires_at_epoch.saturating_sub(now_epoch_seconds()).max(1);
148    let value = encode_session_record(CachedSessionRecord {
149        user_id,
150        expires_at_epoch,
151    });
152    let _ = set_value(&key, &value, ttl);
153    let _ = set_add(&index_key, &key, ttl);
154}
155
156pub fn session_cache_revoke_token(app_id: &str, session_token_hash: &str) {
157    if !session_cache_enabled() {
158        return;
159    }
160    let Some(key) = cache_key::session_value(app_id, session_token_hash) else {
161        return;
162    };
163    let Some(key) = prefix_runtime_key(key) else {
164        return;
165    };
166    let _ = delete_value(&key);
167}
168
169pub fn session_cache_revoke_user(app_id: &str, user_id: i64) {
170    if !session_cache_enabled() || user_id <= 0 {
171        return;
172    }
173    let Some(index_key) = cache_key::session_user_index(app_id, user_id) else {
174        return;
175    };
176    let Some(index_key) = prefix_runtime_key(index_key) else {
177        return;
178    };
179    let keys = set_members(&index_key);
180    if !keys.is_empty() {
181        let borrowed = keys.iter().map(String::as_str).collect::<Vec<_>>();
182        let _ = delete_many(&borrowed);
183    }
184    let _ = delete_value(&index_key);
185}
186
187pub fn route_match_lookup(path: &str) -> Option<String> {
188    if !route_match_cache_enabled() {
189        return None;
190    }
191    let generation = cache_state_read(|state| state.route_match_generation).unwrap_or(0);
192    let key = prefix_runtime_key(cache_key::route_match(&format!("{generation}:{path}"))?)?;
193    get_value(&key)
194}
195
196pub fn route_match_store(path: &str, app_id: &str) {
197    if !route_match_cache_enabled() || app_id.is_empty() {
198        return;
199    }
200    let generation = cache_state_read(|state| state.route_match_generation).unwrap_or(0);
201    let Some(key) = cache_key::route_match(&format!("{generation}:{path}")) else {
202        return;
203    };
204    let Some(key) = prefix_runtime_key(key) else {
205        return;
206    };
207    let _ = set_value(&key, app_id, 300);
208}
209
210pub fn invalidate_route_match_namespace() {
211    let Some(state) = RUNTIME_CACHE_STATE.get() else {
212        return;
213    };
214    if let Ok(mut state) = state.write() {
215        state.route_match_generation = state.route_match_generation.saturating_add(1);
216    }
217}
218
219pub fn template_source_lookup(app_root: &Path, relative_path: &str) -> Option<String> {
220    if !template_source_cache_enabled() {
221        return None;
222    }
223    let app_root = app_root.to_string_lossy();
224    let key = prefix_runtime_key(cache_key::template_source(&app_root, relative_path)?)?;
225    get_value(&key)
226}
227
228pub fn template_source_store(app_root: &Path, relative_path: &str, template_text: &str) {
229    if !template_source_cache_enabled() || template_text.is_empty() {
230        return;
231    }
232    let app_root = app_root.to_string_lossy();
233    let Some(key) = cache_key::template_source(&app_root, relative_path) else {
234        return;
235    };
236    let Some(key) = prefix_runtime_key(key) else {
237        return;
238    };
239    let _ = set_value(&key, template_text, 60);
240}
241
242pub fn dynamic_config_allowlist_value(
243    app_root: &Path,
244    runtime_conf_path: Option<&Path>,
245    key: &str,
246) -> Option<String> {
247    if key.is_empty() {
248        return None;
249    }
250    let runtime_conf_str = runtime_conf_path.map(|path| path.to_string_lossy().into_owned());
251    if config_allowlist_cache_enabled() && config_key_allowlisted(key) {
252        let app_root_str = app_root.to_string_lossy();
253        let cache_key = prefix_runtime_key(cache_key::config_allowlist(
254            &app_root_str,
255            runtime_conf_str.as_deref(),
256            key,
257        )?)?;
258        if let Some(value) = get_value(&cache_key) {
259            return (!value.is_empty()).then_some(value);
260        }
261        let value = config::dynamic_config_effective_value(app_root, runtime_conf_path, key)?;
262        let _ = set_value(&cache_key, &value, 30);
263        return Some(value);
264    }
265    config::dynamic_config_effective_value(app_root, runtime_conf_path, key)
266}
267
268fn session_cache_enabled() -> bool {
269    cache_state_read(|state| state.enabled && state.cache_sessions_enabled).unwrap_or(false)
270}
271
272fn template_source_cache_enabled() -> bool {
273    cache_state_read(|state| state.enabled && state.cache_template_source_enabled).unwrap_or(false)
274}
275
276fn route_match_cache_enabled() -> bool {
277    cache_state_read(|state| state.enabled && state.cache_route_match_enabled).unwrap_or(false)
278}
279
280fn config_allowlist_cache_enabled() -> bool {
281    cache_state_read(|state| state.enabled && state.cache_config_allowlist_enabled).unwrap_or(false)
282}
283
284fn config_key_allowlisted(key: &str) -> bool {
285    matches!(
286        key,
287        "environment"
288            | "base_path"
289            | "base_url"
290            | "payment_providers"
291            | "stripe_client_id"
292            | "stripe_client_secret_env"
293            | "stripe_webhook_secret_env"
294            | "payment_provider_state_key_env"
295            | "payment_provider_secret_key_env"
296            | "payment_provider_secret_key_version"
297            | "payment_provider_secret_previous_key_version"
298            | "payment_provider_secret_previous_key_env"
299            | "payment_provider_allow_loopback_http"
300            | "payment_provider_allow_plaintext_local"
301            | "stripe_oauth_authorize_url"
302            | "stripe_oauth_token_url"
303            | "stripe_account_api_base_url"
304            | "stripe_oauth_deauthorize_url"
305    )
306}
307
308fn encode_session_record(record: CachedSessionRecord) -> String {
309    format!("{}|{}", record.user_id, record.expires_at_epoch)
310}
311
312fn decode_session_record(value: &str) -> Option<CachedSessionRecord> {
313    let mut fields = value.split('|');
314    let user_id = fields.next()?.parse::<i64>().ok()?;
315    let expires_at_epoch = fields.next()?.parse::<u64>().ok()?;
316    Some(CachedSessionRecord {
317        user_id,
318        expires_at_epoch,
319    })
320}
321
322fn now_epoch_seconds() -> u64 {
323    SystemTime::now()
324        .duration_since(UNIX_EPOCH)
325        .map(|value| value.as_secs())
326        .unwrap_or(0)
327}
328
329fn cache_state_read<T>(reader: impl FnOnce(&RuntimeCacheState) -> T) -> Option<T> {
330    let state = RUNTIME_CACHE_STATE.get()?;
331    let state = state.read().ok()?;
332    Some(reader(&state))
333}
334
335fn prefix_runtime_key(key: String) -> Option<String> {
336    let prefix = cache_state_read(|state| state.cache_prefix.clone()).unwrap_or_default();
337    (!prefix.is_empty() && !key.is_empty()).then(|| format!("{prefix}:{key}"))
338}
339
340#[cfg(feature = "redis")]
341fn runtime_config_from_serve_config(config_record: &ServeConfig) -> RedisRuntimeConfig {
342    RedisRuntimeConfig {
343        enabled: config_record.redis_enabled,
344        host: config_record
345            .redis_host
346            .clone()
347            .filter(|value| !value.is_empty())
348            .unwrap_or_else(|| "127.0.0.1".to_owned()),
349        port: config_record
350            .redis_port
351            .as_deref()
352            .and_then(|value| value.parse::<u16>().ok())
353            .unwrap_or(6379),
354        auth_user: config_record
355            .redis_auth_user
356            .clone()
357            .filter(|value| !value.is_empty())
358            .unwrap_or_else(|| "default".to_owned()),
359        auth_token_env: config_record
360            .redis_auth_token_env
361            .clone()
362            .unwrap_or_default(),
363        db_index: config_record
364            .redis_db_index
365            .as_deref()
366            .and_then(|value| value.parse::<i64>().ok())
367            .unwrap_or(0),
368        connect_timeout_ms: config_record
369            .redis_connect_timeout_ms
370            .as_deref()
371            .and_then(|value| value.parse::<u64>().ok())
372            .unwrap_or(500),
373        read_timeout_ms: config_record
374            .redis_read_timeout_ms
375            .as_deref()
376            .and_then(|value| value.parse::<u64>().ok())
377            .unwrap_or(300),
378        cache_prefix: config_record
379            .redis_cache_prefix
380            .clone()
381            .filter(|value| !value.is_empty())
382            .unwrap_or_else(|| "pharos".to_owned()),
383        backoff_seconds: config_record
384            .redis_backoff_seconds
385            .as_deref()
386            .and_then(|value| value.parse::<u64>().ok())
387            .unwrap_or(30),
388        backoff_max_seconds: config_record
389            .redis_backoff_max_seconds
390            .as_deref()
391            .and_then(|value| value.parse::<u64>().ok())
392            .unwrap_or(300),
393        tls_mode: config_record
394            .redis_tls_mode
395            .clone()
396            .filter(|value| !value.is_empty())
397            .unwrap_or_else(|| "disable".to_owned()),
398        tls_root_cert: config_record
399            .redis_tls_root_cert
400            .clone()
401            .unwrap_or_default(),
402        tls_cert: config_record.redis_tls_cert.clone().unwrap_or_default(),
403        tls_key: config_record.redis_tls_key.clone().unwrap_or_default(),
404        require_peer_name_match: config_record.redis_require_peer_name_match,
405        cache_sessions_enabled: config_record.redis_cache_sessions_enabled,
406        cache_template_source_enabled: config_record.redis_cache_template_source_enabled,
407        cache_route_match_enabled: config_record.redis_cache_route_match_enabled,
408        cache_config_allowlist_enabled: config_record.redis_cache_config_allowlist_enabled,
409    }
410}
411
412#[cfg(feature = "redis")]
413fn with_connection<T>(
414    action: impl FnOnce(&RedisConnectionFactory, &mut RedisConnection) -> Result<T, RedisError>,
415) -> Option<T> {
416    let factory = cache_state_read(|state| {
417        if !state.enabled || now_epoch_seconds() < state.next_connect_after_epoch {
418            None
419        } else {
420            state.factory.clone()
421        }
422    })
423    .flatten()?;
424
425    REDIS_CONNECTION.with(|cell| {
426        if cell.borrow().is_none() {
427            update_status(RedisStatus::Connecting, 0, None);
428            let mut connection = factory.connect().ok()?;
429            factory.ping(&mut connection).ok()?;
430            *cell.borrow_mut() = Some(connection);
431            update_status(
432                RedisStatus::Active,
433                factory.config().backoff_seconds.max(1),
434                Some(0),
435            );
436        }
437        let result = {
438            let mut borrow = cell.borrow_mut();
439            let connection = borrow.as_mut()?;
440            action(&factory, connection)
441        };
442        match result {
443            Ok(value) => Some(value),
444            Err(_) => {
445                cell.borrow_mut().take();
446                schedule_backoff(&factory);
447                None
448            }
449        }
450    })
451}
452
453#[cfg(not(feature = "redis"))]
454fn get_value(_key: &str) -> Option<String> {
455    None
456}
457
458#[cfg(feature = "redis")]
459fn get_value(key: &str) -> Option<String> {
460    with_connection(|factory, connection| factory.get_text(connection, key)).flatten()
461}
462
463#[cfg(not(feature = "redis"))]
464fn set_value(_key: &str, _value: &str, _ttl_seconds: u64) -> Option<()> {
465    None
466}
467
468#[cfg(feature = "redis")]
469fn set_value(key: &str, value: &str, ttl_seconds: u64) -> Option<()> {
470    with_connection(|factory, connection| {
471        factory.set_text_ttl(connection, key, value, ttl_seconds)?;
472        Ok(())
473    })
474}
475
476#[cfg(not(feature = "redis"))]
477fn delete_value(_key: &str) -> Option<()> {
478    None
479}
480
481#[cfg(feature = "redis")]
482fn delete_value(key: &str) -> Option<()> {
483    with_connection(|factory, connection| {
484        factory.delete(connection, key)?;
485        Ok(())
486    })
487}
488
489#[cfg(not(feature = "redis"))]
490fn delete_many(_keys: &[&str]) -> Option<()> {
491    None
492}
493
494#[cfg(feature = "redis")]
495fn delete_many(keys: &[&str]) -> Option<()> {
496    with_connection(|factory, connection| {
497        factory.delete_many(connection, keys.iter().copied())?;
498        Ok(())
499    })
500}
501
502#[cfg(not(feature = "redis"))]
503fn set_add(_key: &str, _member: &str, _ttl_seconds: u64) -> Option<()> {
504    None
505}
506
507#[cfg(feature = "redis")]
508fn set_add(key: &str, member: &str, ttl_seconds: u64) -> Option<()> {
509    with_connection(|factory, connection| {
510        factory.set_add(connection, key, member)?;
511        factory.set_expire(connection, key, ttl_seconds)?;
512        Ok(())
513    })
514}
515
516#[cfg(not(feature = "redis"))]
517fn set_members(_key: &str) -> Vec<String> {
518    Vec::new()
519}
520
521#[cfg(feature = "redis")]
522fn set_members(key: &str) -> Vec<String> {
523    with_connection(|factory, connection| factory.set_members(connection, key)).unwrap_or_default()
524}
525
526#[cfg(feature = "redis")]
527fn schedule_backoff(factory: &RedisConnectionFactory) {
528    let backoff = cache_state_read(|state| state.current_backoff_seconds)
529        .unwrap_or(factory.config().backoff_seconds.max(1));
530    let next_backoff = backoff
531        .saturating_mul(2)
532        .min(factory.config().backoff_max_seconds.max(backoff));
533    update_status(
534        RedisStatus::Backoff,
535        next_backoff,
536        Some(now_epoch_seconds().saturating_add(backoff)),
537    );
538}
539
540#[cfg(feature = "redis")]
541fn update_status(status: RedisStatus, backoff_seconds: u64, next_connect_after_epoch: Option<u64>) {
542    let Some(state) = RUNTIME_CACHE_STATE.get() else {
543        return;
544    };
545    let Ok(mut state) = state.write() else {
546        return;
547    };
548    state.status = status.as_str().to_owned();
549    state.current_backoff_seconds = backoff_seconds.max(1);
550    if let Some(next_connect_after_epoch) = next_connect_after_epoch {
551        state.next_connect_after_epoch = next_connect_after_epoch;
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::config_key_allowlisted;
558
559    #[test]
560    fn payment_provider_endpoint_config_keys_are_allowlisted_for_runtime_cache() {
561        for key in [
562            "payment_providers",
563            "stripe_client_id",
564            "stripe_client_secret_env",
565            "stripe_webhook_secret_env",
566            "payment_provider_state_key_env",
567            "payment_provider_secret_key_env",
568            "payment_provider_secret_key_version",
569            "payment_provider_secret_previous_key_version",
570            "payment_provider_secret_previous_key_env",
571            "payment_provider_allow_loopback_http",
572            "payment_provider_allow_plaintext_local",
573            "stripe_oauth_authorize_url",
574            "stripe_oauth_token_url",
575            "stripe_account_api_base_url",
576            "stripe_oauth_deauthorize_url",
577        ] {
578            assert!(config_key_allowlisted(key), "{key} must be allowlisted");
579        }
580    }
581}