Skip to content

Commit 285fc6c

Browse files
authored
Merge pull request #8 from msalinas92/feature/refresh-cache
Probabilistic Cache Refreshing
2 parents 7a145ed + f8e4347 commit 285fc6c

File tree

18 files changed

+245
-71
lines changed

18 files changed

+245
-71
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ metrics = "0.24.2"
5656
metrics-macros = "0.7.1"
5757
metrics-exporter-prometheus = "0.17.0"
5858
futures = "0.3"
59-
59+
rand = "0.8"
60+
tower = "0.5.2"
6061

6162

6263
[dev-dependencies]

README.md

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,36 +91,86 @@ Check if URI is marked as degraded (should_failover)
9191
└── Downstream failed --> try_cache fallback
9292
```
9393

94+
---
95+
## 🔁 Probabilistic Cache Refreshing
96+
97+
To ensure cached responses stay fresh over time, CacheBolt supports **probabilistic refreshes**.
98+
You can configure a percentage of requests that will intentionally bypass the cache and fetch a fresh version from the backend.
99+
100+
```yaml
101+
cache:
102+
refresh_percentage: 10
103+
```
104+
105+
In the example above, approximately 1 in every 10 requests to the same cache key will bypass the memory and persistent cache and trigger a revalidation from the upstream server.
106+
The refreshed response is then stored again in both memory and persistent storage backends.
107+
108+
This strategy helps:
109+
110+
Keep long-lived cache entries updated
111+
112+
Avoid cache staleness without needing manual invalidation
113+
114+
Distribute backend load gradually and intelligently
115+
116+
If set to 0, no automatic refresh will occur unless the cache is manually purged.
94117
95118
---
96119
## 🔧 Configuration
97120
98121
The config is written in YAML. Example:
99122
100123
```yaml
124+
# 🔧 Unique identifier for this CacheBolt instance
101125
app_id: my-service
102126

127+
# 🚦 Maximum number of concurrent outbound requests to the downstream service
103128
max_concurrent_requests: 200
129+
130+
# 🌐 Base URL of the upstream API/backend to which requests are proxied
104131
downstream_base_url: http://localhost:4000
132+
133+
# ⏱️ Timeout (in seconds) for downstream requests before failing
105134
downstream_timeout_secs: 5
106135

107-
storage_backend: s3 # options: gcs, s3, azure, local
136+
# 💾 Backend used for persistent cache storage
137+
# Available options: gcs, s3, azure, local
138+
storage_backend: s3
139+
140+
# 🪣 Name of the Google Cloud Storage bucket (used if storage_backend is 'gcs')
108141
gcs_bucket: cachebolt
142+
143+
# 🪣 Name of the Amazon S3 bucket (used if storage_backend is 's3')
109144
s3_bucket: my-cachebolt-bucket
145+
146+
# 📦 Name of the Azure Blob Storage container (used if storage_backend is 'azure')
110147
azure_container: cachebolt-container
111148

112-
memory_eviction:
113-
threshold_percent: 90
149+
# 🧠 Memory cache configuration
150+
cache:
151+
# 🚨 System memory usage threshold (%) above which in-memory cache will start evicting entries
152+
memory_threshold: 80
153+
154+
# 🔁 Percentage of requests (per key) that should trigger a refresh from backend instead of using cache
155+
# Example: 10% means 1 in every 10 requests will bypass cache
156+
refresh_percentage: 10
114157

158+
# ⚠️ Latency-based failover configuration
115159
latency_failover:
116-
default_max_latency_ms: 300
160+
# ⌛ Default maximum allowed latency in milliseconds for any request
161+
default_max_latency_ms: 3000
162+
163+
# 🛣️ Path-specific latency thresholds
117164
path_rules:
118165
- pattern: "^/api/v1/products/.*"
119-
max_latency_ms: 150
166+
max_latency_ms: 1500
120167
- pattern: "^/auth/.*"
121-
max_latency_ms: 100
168+
max_latency_ms: 1000
169+
170+
# 🚫 List of request headers to ignore when computing cache keys (case-insensitive)
122171
ignored_headers:
123172
- postman-token
173+
- if-none-match
124174
```
125175
126176
---

config.yaml

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,50 @@
1+
# 🔧 Unique identifier for this CacheBolt instance
12
app_id: my-service
23

4+
# 🚦 Maximum number of concurrent outbound requests to the downstream service
35
max_concurrent_requests: 200
6+
7+
# 🌐 Base URL of the upstream API/backend to which requests are proxied
48
downstream_base_url: http://localhost:4000
9+
10+
# ⏱️ Timeout (in seconds) for downstream requests before failing
511
downstream_timeout_secs: 5
612

7-
storage_backend: s3 # opciones: gcs, s3, azure, local
13+
# 💾 Backend used for persistent cache storage
14+
# Available options: gcs, s3, azure, local
15+
storage_backend: s3
16+
17+
# 🪣 Name of the Google Cloud Storage bucket (used if storage_backend is 'gcs')
818
gcs_bucket: cachebolt
19+
20+
# 🪣 Name of the Amazon S3 bucket (used if storage_backend is 's3')
921
s3_bucket: my-cachebolt-bucket
22+
23+
# 📦 Name of the Azure Blob Storage container (used if storage_backend is 'azure')
1024
azure_container: cachebolt-container
1125

26+
# 🧠 Memory cache configuration
27+
cache:
28+
# 🚨 System memory usage threshold (%) above which in-memory cache will start evicting entries
29+
memory_threshold: 80
1230

13-
memory_eviction:
14-
threshold_percent: 90
31+
# 🔁 Percentage of requests (per key) that should trigger a refresh from backend instead of using cache
32+
# Example: 10% means 1 in every 10 requests will bypass cache
33+
refresh_percentage: 10
1534

35+
# ⚠️ Latency-based failover configuration
1636
latency_failover:
17-
default_max_latency_ms: 300
37+
# ⌛ Default maximum allowed latency in milliseconds for any request
38+
default_max_latency_ms: 3000
39+
40+
# 🛣️ Path-specific latency thresholds
1841
path_rules:
1942
- pattern: "^/api/v1/products/.*"
20-
max_latency_ms: 150
43+
max_latency_ms: 1500
2144
- pattern: "^/auth/.*"
22-
max_latency_ms: 100
45+
max_latency_ms: 1000
2346

47+
# 🚫 List of request headers to ignore when computing cache keys (case-insensitive)
2448
ignored_headers:
2549
- postman-token
26-
- if-none-match
50+
- if-none-match

src/admin/clean.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
// Copyright (C) 2025 Matías Salinas ([email protected])
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
115
use crate::memory::memory::MEMORY_CACHE;
216
use axum::{extract::Query, http::StatusCode, response::IntoResponse, Json};
317
use serde::{Deserialize, Serialize};

src/config.rs

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ use serde::Deserialize;
1717
use std::{collections::HashSet, error::Error, fs};
1818

1919
/// Supported persistent storage backends for the cache.
20-
/// This enum is deserialized from lowercase strings in the YAML config.
2120
#[derive(Debug, Deserialize, PartialEq, Clone)]
2221
#[serde(rename_all = "lowercase")]
2322
pub enum StorageBackend {
@@ -27,20 +26,23 @@ pub enum StorageBackend {
2726
Local,
2827
}
2928

30-
/// Configuration for memory-based eviction strategy.
31-
/// Eviction triggers when system memory usage exceeds a certain percentage.
29+
/// Cache-related settings for memory usage and re-cache policies.
3230
#[derive(Debug, Deserialize, Clone)]
33-
pub struct MemoryEviction {
31+
pub struct CacheSettings {
3432
/// Memory usage threshold as a percentage (e.g., 80 = 80%).
35-
pub threshold_percent: usize,
33+
pub memory_threshold: usize,
34+
35+
/// Percentage of fallback requests that should attempt revalidation.
36+
#[serde(default)]
37+
pub refresh_percentage: u8,
3638
}
3739

3840
/// Describes latency thresholds per path to decide when to fallback to the cache.
39-
/// Useful for protecting the system when downstream responses become too slow.
4041
#[derive(Debug, Deserialize, Clone)]
4142
pub struct MaxLatencyRule {
4243
/// Regex pattern to match request paths (e.g., ^/api/products).
4344
pub pattern: String,
45+
4446
/// Maximum allowable response time in milliseconds for this pattern.
4547
pub max_latency_ms: u64,
4648
}
@@ -50,6 +52,7 @@ pub struct MaxLatencyRule {
5052
pub struct LatencyFailover {
5153
/// Default latency limit in milliseconds if no rule matches.
5254
pub default_max_latency_ms: u64,
55+
5356
/// Specific path-based rules, applied in order.
5457
pub path_rules: Vec<MaxLatencyRule>,
5558
}
@@ -79,8 +82,8 @@ pub struct Config {
7982
/// Timeout for downstream requests in seconds.
8083
pub downstream_timeout_secs: u64,
8184

82-
/// Memory eviction policy settings.
83-
pub memory_eviction: MemoryEviction,
85+
/// Cache settings including memory limits and re-cache rules.
86+
pub cache: CacheSettings,
8487

8588
/// Latency-based failover rules.
8689
pub latency_failover: LatencyFailover,
@@ -115,18 +118,28 @@ impl Config {
115118
StorageBackend::Gcs if parsed.gcs_bucket.trim().is_empty() => {
116119
return Err("GCS backend selected but gcs_bucket is empty.".into());
117120
}
121+
StorageBackend::S3 if parsed.s3_bucket.trim().is_empty() => {
122+
return Err("S3 backend selected but s3_bucket is empty.".into());
123+
}
124+
StorageBackend::Azure if parsed.azure_container.trim().is_empty() => {
125+
return Err("Azure backend selected but azure_container is empty.".into());
126+
}
118127
_ => {}
119128
}
120129

121-
// Provide info logs about latency fallback rules
130+
// Validate memory threshold
131+
if parsed.cache.memory_threshold == 0 || parsed.cache.memory_threshold > 100 {
132+
return Err("cache.memory_threshold must be between 1 and 100.".into());
133+
}
134+
135+
// Log latency failover rules
122136
if parsed.latency_failover.path_rules.is_empty() {
123137
tracing::info!(
124138
"No per-path latency rules defined. Using default max latency: {}ms",
125139
parsed.latency_failover.default_max_latency_ms
126140
);
127141
} else {
128142
for rule in &parsed.latency_failover.path_rules {
129-
130143
tracing::info!(
131144
"Latency rule: pattern = '{}', max_latency = {}ms",
132145
rule.pattern,
@@ -138,6 +151,7 @@ impl Config {
138151
Ok(parsed)
139152
}
140153

154+
/// Returns the list of headers to ignore (lowercased).
141155
pub fn ignored_headers_set(&self) -> HashSet<String> {
142156
self.ignored_headers
143157
.clone()
@@ -146,4 +160,4 @@ impl Config {
146160
.map(|h| h.to_ascii_lowercase())
147161
.collect()
148162
}
149-
}
163+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ async fn main() {
192192
// ------------------------------------------------------
193193
let app = Router::new()
194194
.route("/metrics", get(move || async move { handle.render() }))
195+
.route("/", get(proxy::proxy_handler))
195196
.route("/*path", get(proxy::proxy_handler))
196197
.route("/cache", delete(invalidate_handler));
197198

src/memory/memory.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,14 @@ pub async fn load_into_memory(data: Vec<(String, CachedResponse)>) {
7373
/// Monitors system memory usage and evicts LRU entries if usage exceeds the configured threshold.
7474
/// This function is designed to prevent the application from consuming too much system memory.
7575
///
76-
/// The threshold is defined in `config.yaml` under `memory_eviction.threshold_percent`.
76+
/// The threshold is defined in `config.yaml` under `cache.memory_threshold`.
7777
///
7878
/// # Arguments
7979
/// * `cache` - A mutable reference to the global LRU cache to perform eviction on.
8080
pub async fn maybe_evict_if_needed(cache: &mut LruCache<String, CachedResponse, RandomState>) {
8181
let config = CONFIG.get();
8282
let threshold_percent = config
83-
.map(|c| c.memory_eviction.threshold_percent)
83+
.map(|c| c.cache.memory_threshold)
8484
.unwrap_or(80);
8585

8686
let (used_kib, total_kib) = get_memory_usage_kib();

src/proxy.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,16 @@ use hyper::client::HttpConnector;
1717
use hyper::{Body, Client, Request, Response};
1818
use once_cell::sync::Lazy;
1919
use sha2::{Digest, Sha256};
20-
use std::sync::Arc;
20+
use std::sync::{Arc};
2121
use tokio::sync::{Semaphore, mpsc};
2222
use tokio::time::{Duration, Instant, timeout};
2323

2424
use crate::config::{CONFIG, StorageBackend};
2525
use crate::memory::memory;
2626
use crate::rules::latency::{get_max_latency_for_path, mark_latency_fail, should_failover};
2727
use crate::storage::{azure, gcs, local, s3};
28+
use crate::rules::refresh::should_refresh;
29+
2830
use metrics::{counter, histogram}; // ✅
2931

3032
// ------------------------------------------
@@ -117,17 +119,23 @@ pub async fn proxy_handler(req: Request<Body>) -> impl IntoResponse {
117119
let key_source = format!("{}|{}", uri, relevant_headers);
118120
let key = hash_uri(&key_source);
119121

122+
let force_refresh = should_refresh(&key);
123+
124+
125+
120126
// If the URI is in failover mode, serve from cache
121-
if should_failover(&uri) {
127+
if should_failover(&uri) && !force_refresh{
122128
tracing::info!("⚠️ Using fallback for '{}'", uri);
123129
counter!("cachebolt_failover_total", "uri" => uri.clone()).increment(1);
124130
return try_cache(&key).await;
125131
}
126132

127133
// Try memory cache first
128134
if let Some(cached) = memory::get_from_memory(&key).await {
129-
counter!("cachebolt_memory_hits_total", "uri" => uri.clone()).increment(1);
130-
return build_response(cached.body.clone(), cached.headers.clone());
135+
if !force_refresh {
136+
counter!("cachebolt_memory_hits_total", "uri" => uri.clone()).increment(1);
137+
return build_response(cached.body.clone(), cached.headers.clone());
138+
}
131139
}
132140

133141
// Try to acquire concurrency slot

src/rules/latency.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub fn should_failover(uri: &str) -> bool {
3232
let now = Instant::now();
3333
let map = LATENCY_FAILS.read().unwrap();
3434
if let Some(&last_fail) = map.get(&key) {
35-
now.duration_since(last_fail) < Duration::from_secs(300)
35+
now.duration_since(last_fail) < Duration::from_secs(10)
3636
} else {
3737
false
3838
}

0 commit comments

Comments
 (0)