Skip to content

Commit 9006592

Browse files
authored
Merge pull request #4 from msalinas92/feature/delete-from-cache
Feature/delete from cache
2 parents 869004e + f8063c7 commit 9006592

File tree

11 files changed

+331
-11
lines changed

11 files changed

+331
-11
lines changed

Cargo.lock

Lines changed: 3 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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ sysinfo = { version = "0.35.1", features = ["multithread"] }
4646
aws-config = { version = "1.6.3", features = ["behavior-version-latest"] }
4747
aws-sdk-s3 = "1.90.0"
4848
aws-types = "1.3.7"
49-
azure_storage = { version = "0.21.0", default-features = false, features = ["enable_reqwest_rustls"] }
49+
azure_storage = { version = "0.21.0", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"] }
5050
azure_storage_blobs = { version = "0.21.0", default-features = false }
5151
hex = "0.4.3"
5252
bincode = "2.0.1"
@@ -55,6 +55,8 @@ regex = "1.11.1"
5555
metrics = "0.24.2"
5656
metrics-macros = "0.7.1"
5757
metrics-exporter-prometheus = "0.17.0"
58+
futures = "0.3"
59+
5860

5961

6062
[dev-dependencies]

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,35 @@ CacheBolt exposes Prometheus-compatible metrics at the `/metrics` endpoint on po
245245
- `cachebolt_fallback_miss_total`
246246
Count of failover attempts that missed both memory and persistent storage.
247247

248+
---
249+
## 🧹 Cache Invalidation
250+
251+
You can clear the entire cache (both in-memory and persistent storage) using the `/cache?backend=true` endpoint. This is useful when deploying major updates or invalidating stale content globally.
252+
253+
- When `backend=true`, CacheBolt will delete all cache entries stored in:
254+
- 🟢 Amazon S3
255+
- 🔵 Google Cloud Storage
256+
- 🔶 Azure Blob Storage
257+
- 💽 Local Filesystem
258+
259+
### ❌ Pattern-based deletion limitations
260+
261+
If you attempt to use `pattern=...` with `backend=true`, CacheBolt will return an error. Pattern-based invalidation is **only supported in-memory**, not in persistent storage.
262+
263+
### ✅ Example: Full cache invalidation
264+
265+
```bash
266+
curl -X DELETE "http://localhost:3000/cache?backend=true"
267+
```
268+
269+
This will:
270+
271+
Clear all in-memory cache
272+
273+
Batch-delete all objects under the prefix cache/{app_id}/ from the configured storage backend
274+
275+
On S3, it uses optimized DeleteObjects requests (up to 1000 keys per request)
276+
248277
---
249278

250279
## 📄 License

src/admin/clean.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use crate::memory::memory::MEMORY_CACHE;
2+
use axum::{extract::Query, http::StatusCode, response::IntoResponse, Json};
3+
use serde::{Deserialize, Serialize};
4+
5+
// Individual and full backend deletion
6+
use crate::storage::azure::delete_all_from_cache as delete_all_azure;
7+
use crate::storage::gcs::delete_all_from_cache as delete_all_gcs;
8+
use crate::storage::local::delete_all_from_cache as delete_all_local;
9+
use crate::storage::s3::delete_all_from_cache as delete_all_s3;
10+
11+
#[derive(Deserialize)]
12+
pub struct InvalidateParams {
13+
pub backend: Option<bool>,
14+
}
15+
16+
#[derive(Serialize)]
17+
struct SuccessResponse {
18+
message: String,
19+
}
20+
21+
/// DELETE /cache?backend=true
22+
pub async fn invalidate_handler(Query(params): Query<InvalidateParams>) -> impl IntoResponse {
23+
let backend_enabled = params.backend.unwrap_or(false);
24+
25+
// 🧠 Clear memory cache
26+
let mut memory = MEMORY_CACHE.write().await;
27+
let count = memory.len();
28+
memory.clear();
29+
tracing::info!("🧨 Cleared all {count} entries from in-memory cache");
30+
31+
// ☁️ Optionally clear all backends
32+
if backend_enabled {
33+
let futures = vec![
34+
tokio::spawn(async { delete_all_azure().await }),
35+
tokio::spawn(async { delete_all_gcs().await }),
36+
tokio::spawn(async { delete_all_s3().await }),
37+
tokio::spawn(async { delete_all_local().await }),
38+
];
39+
40+
for task in futures {
41+
if let Err(e) = task.await {
42+
tracing::warn!("⚠️ A backend deletion task failed: {:?}", e);
43+
}
44+
}
45+
46+
tracing::info!("🧹 Requested full deletion from all persistent backends");
47+
}
48+
49+
let body = Json(SuccessResponse {
50+
message: if backend_enabled {
51+
format!("Cleared in-memory cache and requested deletion from all backends")
52+
} else {
53+
format!("Cleared in-memory cache only")
54+
},
55+
});
56+
57+
(StatusCode::OK, body)
58+
}

src/admin/mod.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
15+
pub mod clean;

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ pub mod memory;
44
pub mod proxy;
55
pub mod rules;
66
pub mod storage;
7+
pub mod admin;
78

src/main.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,20 @@ mod memory;
2323
mod proxy;
2424
mod rules;
2525
mod storage;
26+
mod admin;
2627

2728
// ----------------------
2829
// External dependencies
2930
// ----------------------
30-
use axum::{Router, routing::get}; // Axum: Web framework for routing and request handling
31+
use axum::{Router, routing::get, routing::delete}; // Axum: Web framework for routing and request handling
3132
use hyper::Server; // Hyper: High-performance HTTP server
3233
use std::{net::SocketAddr, process::exit}; // Network + system utilities
3334

3435
use clap::Parser; // CLI argument parsing (via `--config`)
3536
use tracing::{error, info, warn}; // Structured logging macros
3637
use tracing_subscriber::EnvFilter; // Log filtering via LOG_LEVEL
3738

39+
use crate::admin::clean::invalidate_handler;
3840
// ----------------------
3941
// Internal dependencies
4042
// ----------------------
@@ -190,7 +192,8 @@ async fn main() {
190192
// ------------------------------------------------------
191193
let app = Router::new()
192194
.route("/metrics", get(move || async move { handle.render() }))
193-
.route("/*path", get(proxy::proxy_handler));
195+
.route("/*path", get(proxy::proxy_handler))
196+
.route("/cache", delete(invalidate_handler));
194197

195198
// ------------------------------------------------------
196199
// 8. Bind the server to all interfaces on port 3000

src/storage/azure.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ use crate::config::CONFIG;
2525
use serde::{Serialize, Deserialize};
2626
use base64::engine::general_purpose::STANDARD;
2727
use base64::Engine;
28+
use std::error::Error;
29+
use futures::StreamExt;
2830

2931
/// Structure used to store a cached object in Azure Blob Storage.
3032
/// - `body`: base64-encoded content (response body).
@@ -180,3 +182,52 @@ pub async fn load_from_cache(key: &str) -> Option<(Bytes, Vec<(String, String)>)
180182
}
181183
}
182184
}
185+
186+
/// Deletes all cached entries from Azure Blob Storage (prefix: "cache/{app_id}/").
187+
///
188+
/// # Returns
189+
/// - `Ok(count)` with number of blobs deleted on success.
190+
/// - `Err(...)` if listing or deletion fails.
191+
/// use futures::StreamExt;
192+
pub async fn delete_all_from_cache() -> Result<usize, Box<dyn Error + Send + Sync>> {
193+
let client = AZURE_CLIENT
194+
.get()
195+
.ok_or("Azure client not initialized")?;
196+
197+
let config = CONFIG
198+
.get()
199+
.ok_or("CONFIG not initialized")?;
200+
201+
let container = &config.azure_container;
202+
let container_client = client.container_client(container.clone());
203+
204+
// List all blobs, no prefix
205+
let mut stream = container_client
206+
.list_blobs()
207+
.into_stream();
208+
209+
let mut deleted = 0;
210+
211+
while let Some(result) = stream.next().await {
212+
let result = result?;
213+
let blobs = result.blobs.blobs();
214+
215+
for blob in blobs {
216+
let blob_name = blob.name.clone();
217+
let blob_client = container_client.blob_client(blob_name.clone());
218+
219+
match blob_client.delete().into_future().await {
220+
Ok(_) => {
221+
deleted += 1;
222+
info!("🗑️ Deleted blob '{}' from container '{}'", blob_name, container);
223+
}
224+
Err(e) => {
225+
warn!("⚠️ Failed to delete blob '{}': {}", blob_name, e);
226+
}
227+
}
228+
}
229+
}
230+
231+
info!("✅ Azure: Deleted {deleted} blobs from container '{}'", container);
232+
Ok(deleted)
233+
}

src/storage/gcs.rs

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,11 @@
1717
use google_cloud_storage::{
1818
client::Client,
1919
http::objects::{
20-
download::Range,
21-
get::GetObjectRequest,
22-
upload::{Media, UploadObjectRequest, UploadType},
20+
delete::DeleteObjectRequest, download::Range, get::GetObjectRequest, upload::{Media, UploadObjectRequest, UploadType}
2321
},
2422
};
2523
use bytes::Bytes;
26-
use std::{borrow::Cow};
24+
use std::{borrow::Cow, error::Error};
2725
use std::sync::OnceLock;
2826
use flate2::write::GzEncoder;
2927
use flate2::read::GzDecoder;
@@ -34,6 +32,7 @@ use crate::config::CONFIG;
3432
use serde::{Serialize, Deserialize};
3533
use base64::engine::general_purpose::STANDARD;
3634
use base64::Engine;
35+
use google_cloud_storage::http::objects::list::ListObjectsRequest;
3736

3837
/// Global singleton GCS client instance, initialized at runtime.
3938
pub static GCS_CLIENT: OnceLock<Client> = OnceLock::new();
@@ -185,3 +184,67 @@ pub async fn load_from_cache(key: &str) -> Option<(Bytes, Vec<(String, String)>)
185184
}
186185
}
187186
}
187+
188+
/// Deletes all cached entries from GCS under `cache/{app_id}/`.
189+
///
190+
/// # Returns
191+
/// - `Ok(count)` with number of objects deleted on success.
192+
/// - `Err(...)` if listing or deletion fails.
193+
pub async fn delete_all_from_cache() -> Result<usize, Box<dyn Error + Send + Sync>> {
194+
let client = GCS_CLIENT
195+
.get()
196+
.ok_or("GCS client is not initialized")?;
197+
198+
let config = CONFIG
199+
.get()
200+
.ok_or("CONFIG is not initialized")?;
201+
202+
let app_id = &config.app_id;
203+
let bucket = &config.gcs_bucket;
204+
let prefix = format!("cache/{app_id}/");
205+
206+
let mut page_token: Option<String> = None;
207+
let mut deleted = 0;
208+
209+
loop {
210+
let list_req = ListObjectsRequest {
211+
bucket: bucket.clone(),
212+
prefix: Some(prefix.clone()),
213+
page_token: page_token.clone(),
214+
..Default::default()
215+
};
216+
217+
let objects = client.list_objects(&list_req).await?;
218+
219+
for obj in objects.items.unwrap_or_default() {
220+
let name = &obj.name;
221+
let req = DeleteObjectRequest {
222+
bucket: bucket.clone(),
223+
object: name.clone(),
224+
..Default::default()
225+
};
226+
227+
match client.delete_object(&req).await {
228+
Ok(_) => {
229+
deleted += 1;
230+
info!("🗑️ Deleted '{name}' from bucket '{bucket}'");
231+
}
232+
Err(e) => {
233+
warn!("⚠️ Failed to delete '{name}' from bucket '{bucket}': {e}");
234+
}
235+
}
236+
}
237+
238+
if let Some(token) = objects.next_page_token {
239+
if token.is_empty() {
240+
break;
241+
}
242+
page_token = Some(token);
243+
} else {
244+
break;
245+
}
246+
}
247+
248+
info!("✅ Completed deletion of {deleted} objects under prefix '{prefix}'");
249+
Ok(deleted)
250+
}

0 commit comments

Comments
 (0)