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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})?
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
257
pmocache/tests/test_pinnable.rs
Normal file
257
pmocache/tests/test_pinnable.rs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user