Implémentation des fonctionnalités d'items épinglables et TTL dans PMOcache

Ajout de la capacité à épingler des items pour les protéger de l'éviction LRU et à définir un TTL pour l'expiration automatique des items temporaires.

Cette implémentation inclut :
- Ajout de colonnes `pinned` et `ttl_expires_at` dans la base de données
- Nouvelles méthodes dans DB et Cache pour gérer le pinning et le TTL
- Modification de la politique d'éviction pour exclure les items épinglés
- Implémentation d'une règle métier interdisant le pinning et le TTL simultanément
- API REST complète avec endpoints GET/POST/DELETE pour gérer le pinning et le TTL
- Documentation OpenAPI automatique
- Tests complets couvrant tous les cas d'usage

Les items épinglés ne comptent pas dans la limite du cache et ne peuvent jamais être supprimés automatiquement, tandis que les items avec TTL sont supprimés automatiquement à l'expiration.
This commit is contained in:
2026-01-15 22:16:31 +01:00
parent c250801a9f
commit 2cdc7109d1
11 changed files with 2159 additions and 32 deletions

View File

@@ -112,6 +112,41 @@ pub struct ErrorResponse {
pub message: String,
}
/// Requête pour définir un TTL
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SetTtlRequest {
/// Date/heure d'expiration au format RFC3339
#[cfg_attr(feature = "openapi", schema(example = "2025-01-20T10:30:00Z"))]
pub expires_at: String,
}
/// Réponse pour une opération de pinning/TTL
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PinResponse {
/// Clé primaire de l'item
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// Message de succès
#[cfg_attr(feature = "openapi", schema(example = "Item pinned successfully"))]
pub message: String,
}
/// Statut de pinning d'un item
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PinStatus {
/// Clé primaire de l'item
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// Indique si l'item est épinglé
pub pinned: bool,
/// Date/heure d'expiration du TTL (si défini)
#[cfg_attr(feature = "openapi", schema(example = "2025-01-20T10:30:00Z"))]
pub ttl_expires_at: Option<String>,
}
/// Liste tous les items en cache avec leurs statistiques
///
/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant.
@@ -438,3 +473,229 @@ pub async fn consolidate_cache<C: CacheConfig + 'static>(
.into_response(),
}
}
/// Récupère le statut de pinning d'un item
///
/// Retourne si l'item est épinglé et sa date d'expiration TTL (si défini).
pub async fn get_pin_status<C: CacheConfig + 'static>(
State(cache): State<Arc<Cache<C>>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
// Vérifier que l'item existe et récupérer ses infos
match cache.db.get(&pk, false) {
Ok(entry) => (
StatusCode::OK,
Json(PinStatus {
pk,
pinned: entry.pinned,
ttl_expires_at: entry.ttl_expires_at,
}),
)
.into_response(),
Err(_) => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Item with pk '{}' not found in cache", pk),
}),
)
.into_response(),
}
}
/// Épingle un item pour le protéger de l'éviction LRU
///
/// Un item épinglé ne peut pas être supprimé automatiquement et ne compte pas
/// dans la limite du cache. Échoue si l'item a un TTL défini.
pub async fn pin_item<C: CacheConfig + 'static>(
State(cache): State<Arc<Cache<C>>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
match cache.pin(&pk).await {
Ok(_) => (
StatusCode::OK,
Json(PinResponse {
pk: pk.clone(),
message: format!("Item '{}' pinned successfully", pk),
}),
)
.into_response(),
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("TTL") {
(
StatusCode::CONFLICT,
Json(ErrorResponse {
error: "CONFLICT".to_string(),
message: "Cannot pin an item with TTL set. Clear TTL first.".to_string(),
}),
)
.into_response()
} else if error_msg.contains("no rows") {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Item with pk '{}' not found in cache", pk),
}),
)
.into_response()
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "PIN_ERROR".to_string(),
message: format!("Cannot pin item: {}", e),
}),
)
.into_response()
}
}
}
}
/// Désépingle un item
///
/// Rend l'item à nouveau éligible à l'éviction LRU et le compte dans la limite du cache.
pub async fn unpin_item<C: CacheConfig + 'static>(
State(cache): State<Arc<Cache<C>>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
match cache.unpin(&pk).await {
Ok(_) => (
StatusCode::OK,
Json(PinResponse {
pk: pk.clone(),
message: format!("Item '{}' unpinned successfully", pk),
}),
)
.into_response(),
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("no rows") {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Item with pk '{}' not found in cache", pk),
}),
)
.into_response()
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "UNPIN_ERROR".to_string(),
message: format!("Cannot unpin item: {}", e),
}),
)
.into_response()
}
}
}
}
/// Définit le TTL (Time To Live) d'un item
///
/// L'item sera automatiquement supprimé à la date d'expiration.
/// Échoue si l'item est épinglé.
pub async fn set_item_ttl<C: CacheConfig + 'static>(
State(cache): State<Arc<Cache<C>>>,
Path(pk): Path<String>,
Json(req): Json<SetTtlRequest>,
) -> impl IntoResponse {
// Valider le format de la date
if chrono::DateTime::parse_from_rfc3339(&req.expires_at).is_err() {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "INVALID_DATE".to_string(),
message: "Invalid RFC3339 date format".to_string(),
}),
)
.into_response();
}
match cache.set_ttl(&pk, &req.expires_at).await {
Ok(_) => (
StatusCode::OK,
Json(PinResponse {
pk: pk.clone(),
message: format!("TTL set successfully for item '{}'", pk),
}),
)
.into_response(),
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("pinned") {
(
StatusCode::CONFLICT,
Json(ErrorResponse {
error: "CONFLICT".to_string(),
message: "Cannot set TTL on a pinned item. Unpin first.".to_string(),
}),
)
.into_response()
} else if error_msg.contains("no rows") {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Item with pk '{}' not found in cache", pk),
}),
)
.into_response()
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "TTL_ERROR".to_string(),
message: format!("Cannot set TTL: {}", e),
}),
)
.into_response()
}
}
}
}
/// Supprime le TTL d'un item
///
/// L'item ne sera plus supprimé automatiquement.
pub async fn clear_item_ttl<C: CacheConfig + 'static>(
State(cache): State<Arc<Cache<C>>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
match cache.clear_ttl(&pk).await {
Ok(_) => (
StatusCode::OK,
Json(PinResponse {
pk: pk.clone(),
message: format!("TTL cleared successfully for item '{}'", pk),
}),
)
.into_response(),
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("no rows") {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Item with pk '{}' not found in cache", pk),
}),
)
.into_response()
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "TTL_ERROR".to_string(),
message: format!("Cannot clear TTL: {}", e),
}),
)
.into_response()
}
}
}
}

View File

@@ -1057,6 +1057,67 @@ impl<C: CacheConfig + 'static> Cache<C> {
Ok(())
}
/// Épingle un item pour le protéger de l'éviction LRU
///
/// Un item épinglé ne peut pas être supprimé automatiquement par la politique LRU
/// et ne compte pas dans la limite du cache.
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item à épingler
///
/// # Errors
///
/// Retourne une erreur si l'item a un TTL défini (incompatibilité métier)
pub async fn pin(&self, pk: &str) -> Result<()> {
self.db.pin(pk).map_err(|e| anyhow!(e))
}
/// Désépingle un item pour le rendre à nouveau éligible à l'éviction LRU
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item à désépingler
pub async fn unpin(&self, pk: &str) -> Result<()> {
self.db.unpin(pk).map_err(|e| anyhow!(e))
}
/// Vérifie si un item est épinglé
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item
///
/// # Returns
///
/// `true` si l'item est épinglé, `false` sinon
pub async fn is_pinned(&self, pk: &str) -> Result<bool> {
self.db.is_pinned(pk).map_err(|e| anyhow!(e))
}
/// Définit le TTL (Time To Live) d'un item
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item
/// * `expires_at` - Date/heure d'expiration au format RFC3339
///
/// # Errors
///
/// Retourne une erreur si l'item est épinglé (incompatibilité métier)
pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()> {
self.db.set_ttl(pk, expires_at).map_err(|e| anyhow!(e))
}
/// Supprime le TTL d'un item
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item
pub async fn clear_ttl(&self, pk: &str) -> Result<()> {
self.db.clear_ttl(pk).map_err(|e| anyhow!(e))
}
/// Récupère tous les fichiers d'une collection
///
/// # Arguments
@@ -1386,28 +1447,59 @@ impl<C: CacheConfig + 'static> Cache<C> {
/// Applique la politique d'éviction LRU (Least Recently Used)
///
/// Si le nombre d'entrées dépasse la limite configurée, supprime
/// Si le nombre d'entrées non épinglées dépasse la limite configurée, supprime
/// les entrées les plus anciennes (moins récemment utilisées).
///
/// Les items épinglés sont exclus du comptage et ne peuvent pas être supprimés.
///
/// Cette méthode :
/// 1. Compte le nombre total d'entrées
/// 2. Si > limit, récupère les N entrées les plus anciennes
/// 1. Compte le nombre d'entrées non épinglées
/// 2. Si > limit, récupère les N entrées les plus anciennes (non épinglées)
/// 3. Supprime ces entrées de la DB et leurs fichiers du disque
/// 4. Supprime également les items expirés (TTL dépassé)
///
/// # Returns
///
/// Le nombre d'entrées supprimées
pub async fn enforce_limit(&self) -> Result<usize> {
let count = self.db.count()?;
let mut total_removed = 0;
if count <= self.limit {
return Ok(0);
// 1. Supprimer d'abord les items expirés (TTL dépassé)
let expired_entries = self.db.get_expired()?;
for entry in expired_entries {
if let Ok(paths) = self.get_file_paths(&entry.pk) {
for path in paths {
let _ = tokio::fs::remove_file(path).await;
}
}
if let Err(e) = self.db.delete(&entry.pk) {
tracing::warn!("Error deleting expired entry {} from DB: {}", entry.pk, e);
} else {
total_removed += 1;
tracing::debug!(
"Removed expired item {} (TTL: {:?})",
entry.pk,
entry.ttl_expires_at
);
}
}
// 2. Compter seulement les items non épinglés
let count = self.db.count_unpinned()?;
if count <= self.limit {
if total_removed > 0 {
tracing::info!("Cache cleanup: removed {} expired entries", total_removed);
}
return Ok(total_removed);
}
// 3. Supprimer les plus vieux items non épinglés si nécessaire
let to_remove = count - self.limit;
let old_entries = self.db.get_oldest(to_remove)?;
let mut removed = 0;
let mut lru_removed = 0;
for entry in old_entries {
// Utiliser get_file_paths() pour obtenir tous les fichiers de cette entrée
if let Ok(paths) = self.get_file_paths(&entry.pk) {
@@ -1420,20 +1512,22 @@ impl<C: CacheConfig + 'static> Cache<C> {
if let Err(e) = self.db.delete(&entry.pk) {
tracing::warn!("Error deleting entry {} from DB: {}", entry.pk, e);
} else {
removed += 1;
lru_removed += 1;
}
}
if removed > 0 {
total_removed += lru_removed;
if total_removed > 0 {
tracing::info!(
"LRU eviction: removed {} old entries (cache size: {} -> {})",
removed,
"LRU eviction: removed {} old entries (unpinned cache size: {} -> {})",
lru_removed,
count,
count - removed
count - lru_removed
);
}
Ok(removed)
Ok(total_removed)
}
// ============================================================================

View File

@@ -39,6 +39,12 @@ pub struct CacheEntry {
/// Date/heure du dernier accès (RFC3339)
#[cfg_attr(feature = "openapi", schema(example = "2025-01-15T10:30:00Z"))]
pub last_used: Option<String>,
/// Indique si l'élément est épinglé (ne peut pas être supprimé par LRU)
#[cfg_attr(feature = "openapi", schema(example = false))]
pub pinned: bool,
/// Date/heure d'expiration du TTL (RFC3339), incompatible avec pinned=true
#[cfg_attr(feature = "openapi", schema(example = "2025-01-20T10:30:00Z"))]
pub ttl_expires_at: Option<String>,
/// Métadonnées JSON optionnelles (ex: métadonnées audio, EXIF images, etc.)
#[cfg_attr(
feature = "openapi",
@@ -123,7 +129,9 @@ impl DB {
id TEXT,
hits INTEGER DEFAULT 0,
last_used TEXT,
lazy_pk TEXT
lazy_pk TEXT,
pinned INTEGER DEFAULT 0 CHECK (pinned IN (0, 1)),
ttl_expires_at TEXT
)",
[],
)?;
@@ -141,14 +149,14 @@ impl DB {
// Créer un index sur la collection pour les requêtes rapides
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asset_collection
"CREATE INDEX IF NOT EXISTS idx_asset_collection
ON ASSET (collection)",
[],
)?;
// Créer un index composite pour optimiser la politique LRU (get_oldest)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asset_lru
"CREATE INDEX IF NOT EXISTS idx_asset_lru
ON asset (last_used ASC, hits ASC)",
[],
)?;
@@ -489,7 +497,7 @@ impl DB {
let mut entry = {
let conn = self.lock_conn("get");
conn.query_row(
"SELECT pk, lazy_pk, id, collection, hits, last_used \
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at \
FROM asset \
WHERE pk = ?1",
[pk],
@@ -501,6 +509,8 @@ impl DB {
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
pinned: row.get::<_, i32>(6)? != 0,
ttl_expires_at: row.get::<_, Option<String>>(7)?,
metadata: None,
})
},
@@ -530,7 +540,7 @@ impl DB {
let mut entry = {
let conn = self.lock_conn("get_from_id");
conn.query_row(
"SELECT pk, lazy_pk, id, collection, hits, last_used \
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at \
FROM asset \
WHERE collection = ?1 AND id = ?2",
params![collection, id],
@@ -542,6 +552,8 @@ impl DB {
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
pinned: row.get::<_, i32>(6)? != 0,
ttl_expires_at: row.get::<_, Option<String>>(7)?,
metadata: None,
})
},
@@ -606,8 +618,8 @@ impl DB {
let conn = self.lock_conn("update_hit");
conn.execute(
&"UPDATE asset
SET hits = hits + 1, last_used = ?1
&"UPDATE asset
SET hits = hits + 1, last_used = ?1
WHERE pk = ?2",
params![Utc::now().to_rfc3339(), pk],
)?;
@@ -632,8 +644,8 @@ impl DB {
let conn = self.lock_conn("get_all");
let mut stmt = conn.prepare(
"SELECT pk, lazy_pk, id, collection, hits, last_used
FROM asset
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at
FROM asset
ORDER BY hits DESC",
)?;
@@ -645,6 +657,8 @@ impl DB {
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
pinned: row.get::<_, i32>(6)? != 0,
ttl_expires_at: row.get::<_, Option<String>>(7)?,
metadata: None,
})
})?;
@@ -676,8 +690,8 @@ impl DB {
let conn = self.lock_conn("get_by_collection");
let mut stmt = conn.prepare(
"SELECT pk, lazy_pk, id, collection, hits, last_used
FROM asset
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at
FROM asset
WHERE collection = ?1 ORDER BY hits DESC",
)?;
let rows = stmt.query_map([collection], |row| {
@@ -688,6 +702,8 @@ impl DB {
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
pinned: row.get::<_, i32>(6)? != 0,
ttl_expires_at: row.get::<_, Option<String>>(7)?,
metadata: None,
})
})?;
@@ -732,10 +748,190 @@ impl DB {
Ok(count as usize)
}
/// Compte le nombre d'entrées non épinglées dans le cache
///
/// Les items épinglés ne comptent pas dans la limite du cache.
///
/// # Returns
///
/// Le nombre d'entrées non épinglées
pub fn count_unpinned(&self) -> rusqlite::Result<usize> {
let conn = self.lock_conn("count_unpinned");
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM asset WHERE pinned = 0", [], |row| {
row.get(0)
})?;
Ok(count as usize)
}
/// Épingle un item pour le protéger de l'éviction LRU
///
/// Un item épinglé ne peut pas être supprimé automatiquement par la politique LRU
/// et ne compte pas dans la limite du cache.
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item à épingler
///
/// # Errors
///
/// Retourne une erreur si l'item a un TTL défini (incompatibilité métier)
pub fn pin(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.lock_conn("pin");
// Vérifier que l'item n'a pas de TTL
let has_ttl: bool = conn
.query_row(
"SELECT ttl_expires_at IS NOT NULL FROM asset WHERE pk = ?1",
[pk],
|row| row.get(0),
)
.optional()?
.unwrap_or(false);
if has_ttl {
return Err(Error::InvalidParameterName(
"Cannot pin an item with TTL set".to_owned(),
));
}
let updated = conn.execute("UPDATE asset SET pinned = 1 WHERE pk = ?1", [pk])?;
if updated == 0 {
return Err(Error::QueryReturnedNoRows);
}
Ok(())
}
/// Désépingle un item pour le rendre à nouveau éligible à l'éviction LRU
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item à désépingler
pub fn unpin(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.lock_conn("unpin");
let updated = conn.execute("UPDATE asset SET pinned = 0 WHERE pk = ?1", [pk])?;
if updated == 0 {
return Err(Error::QueryReturnedNoRows);
}
Ok(())
}
/// Vérifie si un item est épinglé
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item
///
/// # Returns
///
/// `true` si l'item est épinglé, `false` sinon
pub fn is_pinned(&self, pk: &str) -> rusqlite::Result<bool> {
let conn = self.lock_conn("is_pinned");
let pinned: i32 =
conn.query_row("SELECT pinned FROM asset WHERE pk = ?1", [pk], |row| {
row.get(0)
})?;
Ok(pinned != 0)
}
/// Définit le TTL (Time To Live) d'un item
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item
/// * `expires_at` - Date/heure d'expiration au format RFC3339
///
/// # Errors
///
/// Retourne une erreur si l'item est épinglé (incompatibilité métier)
pub fn set_ttl(&self, pk: &str, expires_at: &str) -> rusqlite::Result<()> {
let conn = self.lock_conn("set_ttl");
// Vérifier que l'item n'est pas épinglé
let is_pinned: bool = conn
.query_row("SELECT pinned != 0 FROM asset WHERE pk = ?1", [pk], |row| {
row.get(0)
})
.optional()?
.unwrap_or(false);
if is_pinned {
return Err(Error::InvalidParameterName(
"Cannot set TTL on a pinned item".to_owned(),
));
}
let updated = conn.execute(
"UPDATE asset SET ttl_expires_at = ?2 WHERE pk = ?1",
params![pk, expires_at],
)?;
if updated == 0 {
return Err(Error::QueryReturnedNoRows);
}
Ok(())
}
/// Supprime le TTL d'un item
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'item
pub fn clear_ttl(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.lock_conn("clear_ttl");
let updated = conn.execute("UPDATE asset SET ttl_expires_at = NULL WHERE pk = ?1", [pk])?;
if updated == 0 {
return Err(Error::QueryReturnedNoRows);
}
Ok(())
}
/// Récupère les items expirés (TTL dépassé)
///
/// # Returns
///
/// Liste des entrées dont le TTL est dépassé
pub fn get_expired(&self) -> rusqlite::Result<Vec<CacheEntry>> {
let conn = self.lock_conn("get_expired");
let now = Utc::now().to_rfc3339();
let mut stmt = conn.prepare(
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at
FROM asset
WHERE ttl_expires_at IS NOT NULL AND ttl_expires_at < ?1",
)?;
let entries = stmt
.query_map([now], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
lazy_pk: row.get::<_, Option<String>>(1)?,
id: row.get::<_, Option<String>>(2)?,
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
pinned: row.get::<_, i32>(6)? != 0,
ttl_expires_at: row.get::<_, Option<String>>(7)?,
metadata: None,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// Récupère les N entrées les plus anciennes (LRU - Least Recently Used)
///
/// Trie par last_used (les plus anciens en premier), puis par hits (les moins utilisés).
/// Utile pour implémenter une politique d'éviction LRU.
/// Les items épinglés sont EXCLUS de cette liste (ils ne peuvent pas être évincés).
///
/// # Arguments
///
@@ -743,13 +939,14 @@ impl DB {
///
/// # Returns
///
/// Liste des entrées les plus anciennes, triées par last_used ASC
/// Liste des entrées les plus anciennes (non épinglées), triées par last_used ASC
pub fn get_oldest(&self, limit: usize) -> rusqlite::Result<Vec<CacheEntry>> {
let conn = self.lock_conn("get_oldest");
let mut stmt = conn.prepare(
"SELECT pk, lazy_pk, id, collection, hits, last_used
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at
FROM asset
WHERE pinned = 0
ORDER BY last_used ASC, hits ASC
LIMIT ?1",
)?;
@@ -763,6 +960,8 @@ impl DB {
collection: row.get(3)?,
hits: row.get(4)?,
last_used: row.get(5)?,
pinned: row.get::<_, i32>(6)? != 0,
ttl_expires_at: row.get::<_, Option<String>>(7)?,
metadata: None,
})
})?

View File

@@ -155,7 +155,10 @@ pub use lazy::{lazy_prefix_from_pk, LazyEntryRemoteData, LazyProvider};
pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt};
#[cfg(all(feature = "pmoserver", feature = "openapi"))]
pub use api::{AddItemRequest, AddItemResponse, DeleteItemResponse, DownloadStatus, ErrorResponse};
pub use api::{
AddItemRequest, AddItemResponse, DeleteItemResponse, DownloadStatus, ErrorResponse,
PinResponse, PinStatus, SetTtlRequest,
};
#[cfg(feature = "pmoconfig")]
pub use config_ext::CacheConfigExt;

View File

@@ -31,6 +31,11 @@ macro_rules! create_cache_openapi {
$crate::api::delete_item::<Self>,
$crate::api::purge_cache::<Self>,
$crate::api::consolidate_cache::<Self>,
$crate::api::get_pin_status::<Self>,
$crate::api::pin_item::<Self>,
$crate::api::unpin_item::<Self>,
$crate::api::set_item_ttl::<Self>,
$crate::api::clear_item_ttl::<Self>,
),
components(
schemas(
@@ -40,6 +45,9 @@ macro_rules! create_cache_openapi {
$crate::api::AddItemResponse,
$crate::api::DeleteItemResponse,
$crate::api::ErrorResponse,
$crate::api::PinStatus,
$crate::api::PinResponse,
$crate::api::SetTtlRequest,
)
),
tags(

View File

@@ -536,6 +536,11 @@ pub fn create_file_router_with_generator<C: CacheConfig + 'static>(
/// - `GET /{pk}/status` - Status du download
/// - `DELETE /{pk}` - Supprimer un item
/// - `POST /consolidate` - Consolider le cache
/// - `GET /{pk}/pin` - Statut de pinning
/// - `POST /{pk}/pin` - Épingler un item
/// - `DELETE /{pk}/pin` - Désépingler un item
/// - `POST /{pk}/ttl` - Définir le TTL
/// - `DELETE /{pk}/ttl` - Supprimer le TTL
#[cfg(feature = "pmoserver")]
pub fn create_api_router<C: CacheConfig + 'static>(cache: Arc<Cache<C>>) -> Router {
use crate::api;
@@ -552,6 +557,16 @@ pub fn create_api_router<C: CacheConfig + 'static>(cache: Arc<Cache<C>>) -> Rout
get(api::get_item_info::<C>).delete(api::delete_item::<C>),
)
.route("/{pk}/status", get(api::get_download_status::<C>))
.route(
"/{pk}/pin",
get(api::get_pin_status::<C>)
.post(api::pin_item::<C>)
.delete(api::unpin_item::<C>),
)
.route(
"/{pk}/ttl",
post(api::set_item_ttl::<C>).delete(api::clear_item_ttl::<C>),
)
.route("/consolidate", post(api::consolidate_cache::<C>))
.with_state(cache)
}

View File

@@ -0,0 +1,257 @@
use chrono::{Duration, Utc};
use pmocache::{Cache, CacheConfig};
use tempfile::TempDir;
/// Configuration de test simple
struct TestConfig;
impl CacheConfig for TestConfig {
fn file_extension() -> &'static str {
"dat"
}
fn cache_type() -> &'static str {
"test"
}
fn cache_name() -> &'static str {
"testcache"
}
}
type TestCache = Cache<TestConfig>;
fn create_test_cache(limit: usize) -> (TempDir, TestCache) {
let temp_dir = tempfile::tempdir().unwrap();
let cache = TestCache::new(temp_dir.path().to_str().unwrap(), limit).unwrap();
(temp_dir, cache)
}
async fn add_test_file(cache: &TestCache, content: &str) -> String {
let test_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(test_file.path(), content.as_bytes()).unwrap();
cache
.add_from_file(test_file.path().to_str().unwrap(), None)
.await
.unwrap()
}
#[tokio::test]
async fn test_pin_unpin() {
let (_temp_dir, cache) = create_test_cache(10);
let pk = add_test_file(&cache, "Test data").await;
// Vérifier que l'item n'est pas épinglé par défaut
assert!(!cache.is_pinned(&pk).await.unwrap());
// Épingler l'item
cache.pin(&pk).await.unwrap();
assert!(cache.is_pinned(&pk).await.unwrap());
// Désépingler l'item
cache.unpin(&pk).await.unwrap();
assert!(!cache.is_pinned(&pk).await.unwrap());
}
#[tokio::test]
async fn test_pinned_excluded_from_lru() {
// Créer un cache avec une limite de 3 éléments non épinglés
let (_temp_dir, cache) = create_test_cache(3);
let mut pks = Vec::new();
// Ajouter 3 fichiers normaux (atteint la limite)
for i in 0..3 {
let data = format!("File {} data", i);
let pk = add_test_file(&cache, &data).await;
pks.push(pk);
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
// Vérifier qu'on a 3 fichiers
assert_eq!(cache.db.count_unpinned().unwrap(), 3);
// Épingler le 2ème fichier (index 1)
// Cela libère une place dans le comptage des non-épinglés
let pinned_pk = pks[1].clone();
cache.pin(&pinned_pk).await.unwrap();
// Maintenant on a 2 fichiers non épinglés et 1 épinglé
assert_eq!(cache.db.count_unpinned().unwrap(), 2);
assert_eq!(cache.db.count().unwrap(), 3);
// Ajouter 2 fichiers supplémentaires
// Cela devrait déclencher l'éviction du plus vieux fichier non épinglé (index 0)
for i in 3..5 {
let data = format!("File {} data", i);
let pk = add_test_file(&cache, &data).await;
pks.push(pk);
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
// Le cache devrait contenir :
// - 3 fichiers non épinglés (la limite)
// - 1 fichier épinglé
// Total = 4 fichiers
assert_eq!(cache.db.count_unpinned().unwrap(), 3);
assert_eq!(cache.db.count().unwrap(), 4);
// Vérifier que le fichier épinglé est toujours là
assert!(cache.get(&pinned_pk).await.is_ok());
assert!(cache.is_pinned(&pinned_pk).await.unwrap());
// Le premier fichier (index 0, le plus vieux non épinglé) devrait avoir été évincé
assert!(cache.get(&pks[0]).await.is_err());
// Le 3ème fichier (index 2) devrait être présent (non épinglé mais pas le plus vieux)
assert!(cache.get(&pks[2]).await.is_ok());
// Les 2 derniers fichiers devraient être présents
assert!(cache.get(&pks[3]).await.is_ok());
assert!(cache.get(&pks[4]).await.is_ok());
}
#[tokio::test]
async fn test_pinned_count_separately() {
let (_temp_dir, cache) = create_test_cache(5);
// Ajouter 3 fichiers normaux
for i in 0..3 {
let data = format!("File {}", i);
add_test_file(&cache, &data).await;
}
// Ajouter 2 fichiers épinglés
for i in 3..5 {
let data = format!("Pinned file {}", i);
let pk = add_test_file(&cache, &data).await;
cache.pin(&pk).await.unwrap();
}
// Le comptage total devrait être 5
assert_eq!(cache.db.count().unwrap(), 5);
// Le comptage non épinglé devrait être 3
assert_eq!(cache.db.count_unpinned().unwrap(), 3);
}
#[tokio::test]
async fn test_cannot_pin_with_ttl() {
let (_temp_dir, cache) = create_test_cache(10);
let pk = add_test_file(&cache, "Test data").await;
// Définir un TTL
let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
cache.set_ttl(&pk, &expires_at).await.unwrap();
// Essayer d'épingler devrait échouer
assert!(cache.pin(&pk).await.is_err());
}
#[tokio::test]
async fn test_cannot_set_ttl_when_pinned() {
let (_temp_dir, cache) = create_test_cache(10);
let pk = add_test_file(&cache, "Test data").await;
// Épingler l'item
cache.pin(&pk).await.unwrap();
// Essayer de définir un TTL devrait échouer
let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
assert!(cache.set_ttl(&pk, &expires_at).await.is_err());
}
#[tokio::test]
async fn test_ttl_expiration() {
let (_temp_dir, cache) = create_test_cache(10);
// Ajouter un fichier avec un TTL expiré
let pk = add_test_file(&cache, "Expiring data").await;
let expires_at = (Utc::now() - Duration::seconds(1)).to_rfc3339(); // Déjà expiré
cache.set_ttl(&pk, &expires_at).await.unwrap();
// Vérifier que le fichier existe avant l'enforcement
assert!(cache.get(&pk).await.is_ok());
// Déclencher le nettoyage
cache.enforce_limit().await.unwrap();
// Le fichier devrait avoir été supprimé
assert!(cache.get(&pk).await.is_err());
}
#[tokio::test]
async fn test_clear_ttl() {
let (_temp_dir, cache) = create_test_cache(10);
let pk = add_test_file(&cache, "Test data").await;
// Définir un TTL
let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
cache.set_ttl(&pk, &expires_at).await.unwrap();
// Vérifier que le TTL est défini
let entry = cache.db.get(&pk, false).unwrap();
assert!(entry.ttl_expires_at.is_some());
// Supprimer le TTL
cache.clear_ttl(&pk).await.unwrap();
// Vérifier que le TTL a été supprimé
let entry = cache.db.get(&pk, false).unwrap();
assert!(entry.ttl_expires_at.is_none());
// Maintenant on devrait pouvoir épingler
assert!(cache.pin(&pk).await.is_ok());
}
#[tokio::test]
async fn test_get_expired() {
let (_temp_dir, cache) = create_test_cache(10);
// Ajouter un fichier non expiré
let pk1 = add_test_file(&cache, "Non-expired data").await;
let expires_at1 = (Utc::now() + Duration::hours(1)).to_rfc3339();
cache.set_ttl(&pk1, &expires_at1).await.unwrap();
// Ajouter un fichier expiré
let pk2 = add_test_file(&cache, "Expired data").await;
let expires_at2 = (Utc::now() - Duration::seconds(1)).to_rfc3339();
cache.set_ttl(&pk2, &expires_at2).await.unwrap();
// Récupérer les items expirés
let expired = cache.db.get_expired().unwrap();
// Seulement le deuxième fichier devrait être dans la liste
assert_eq!(expired.len(), 1);
assert_eq!(expired[0].pk, pk2);
}
#[tokio::test]
async fn test_cache_entry_fields() {
let (_temp_dir, cache) = create_test_cache(10);
let pk = add_test_file(&cache, "Test data").await;
// Vérifier les valeurs par défaut
let entry = cache.db.get(&pk, false).unwrap();
assert!(!entry.pinned);
assert!(entry.ttl_expires_at.is_none());
// Épingler et vérifier
cache.pin(&pk).await.unwrap();
let entry = cache.db.get(&pk, false).unwrap();
assert!(entry.pinned);
// Désépingler et définir un TTL
cache.unpin(&pk).await.unwrap();
let expires_at = (Utc::now() + Duration::hours(2)).to_rfc3339();
cache.set_ttl(&pk, &expires_at).await.unwrap();
let entry = cache.db.get(&pk, false).unwrap();
assert!(!entry.pinned);
assert!(entry.ttl_expires_at.is_some());
}