rust-py-cache 0.1.3__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. rust_py_cache-0.2.1/CHANGELOG.md +36 -0
  2. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/Cargo.lock +1 -1
  3. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/Cargo.toml +1 -1
  4. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/PKG-INFO +37 -7
  5. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/README.md +36 -6
  6. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/ROADMAP.md +15 -13
  7. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/pyproject.toml +1 -1
  8. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/python/rust_py_cache/__init__.py +1 -1
  9. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/python/rust_py_cache/decorators.py +44 -16
  10. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/cache.rs +108 -12
  11. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/lib.rs +61 -11
  12. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/stats.rs +8 -0
  13. rust_py_cache-0.2.1/src/sweeper.rs +77 -0
  14. rust_py_cache-0.2.1/tests/test_eviction.py +79 -0
  15. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/tests/test_features.py +70 -1
  16. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/.github/workflows/ci.yml +0 -0
  17. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/.github/workflows/release.yml +0 -0
  18. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/.gitignore +0 -0
  19. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/PUBLISHING.md +0 -0
  20. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/examples/django_example.py +0 -0
  21. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/examples/fastapi_example.py +0 -0
  22. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/entry.rs +0 -0
  23. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/ttl.rs +0 -0
  24. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/tests/test_cache_basic.py +0 -0
  25. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/tests/test_delete.py +0 -0
  26. {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/tests/test_set_get.py +0 -0
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ Todas as mudanças relevantes deste projeto são registradas aqui.
4
+
5
+ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
6
+ e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
7
+
8
+ ## [0.2.1] - 2026-08-11
9
+
10
+ ### Corrigido
11
+
12
+ - `@cache.cached` sobre função `async def` guardava no cache o objeto coroutine
13
+ em vez do resultado do `await`, quebrando o decorator já na primeira chamada
14
+ com `TypeError: cannot pickle 'coroutine' object`. O decorator agora detecta
15
+ `inspect.iscoroutinefunction` e gera um wrapper `async` que faz o `await`
16
+ antes do `set`. (BACKLOG2 P0-2)
17
+
18
+ ## [0.2.0]
19
+
20
+ ### Adicionado
21
+
22
+ - Expiração em background via `Sweeper` (thread dedicada com `Weak<T>`,
23
+ encerramento limpo no `Drop`).
24
+ - Evicção por `max_size` com política LRU.
25
+ - Exemplos para FastAPI e Django.
26
+
27
+ ## [0.1.x]
28
+
29
+ ### Adicionado
30
+
31
+ - `Cache` com `set` / `get(default=...)` / `delete` / `exists` / `keys` /
32
+ `len` / `clear` / `cleanup_expired` / `stats`.
33
+ - TTL por entrada com expiração preguiçosa.
34
+ - `stats()` cumulativo baseado em `AtomicU64`.
35
+ - Decorator de memoização `@cache.cached(ttl=..., key=...)`.
36
+ - Core em Rust sobre `DashMap`, valores serializados com `pickle`.
@@ -202,7 +202,7 @@ dependencies = [
202
202
 
203
203
  [[package]]
204
204
  name = "rust_py_cache"
205
- version = "0.1.3"
205
+ version = "0.2.1"
206
206
  dependencies = [
207
207
  "dashmap",
208
208
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rust_py_cache"
3
- version = "0.1.3"
3
+ version = "0.2.1"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-py-cache
3
- Version: 0.1.3
3
+ Version: 0.2.1
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: Implementation :: CPython
@@ -63,7 +63,28 @@ cache.len() # approximate size
63
63
  cache.keys() # list of keys
64
64
  cache.cleanup_expired() # remove expired entries; returns the count
65
65
  cache.clear() # remove everything (keeps counters)
66
- cache.stats() # {'hits', 'misses', 'sets', 'deletes', 'expired', 'size'}
66
+ cache.stats() # {'hits','misses','sets','deletes','expired','evicted','size'}
67
+ ```
68
+
69
+ ### Bounded cache with LRU eviction
70
+
71
+ ```python
72
+ # Cap the number of keys. When full and a new key arrives, evict the
73
+ # least-recently-used entry instead of rejecting the write.
74
+ cache = Cache(max_size=1000, eviction_policy="lru")
75
+ cache.eviction_policy # "lru"
76
+
77
+ # Default policy is "reject": set() returns False when full (and the key is new).
78
+ cache = Cache(max_size=1000) # eviction_policy="reject"
79
+ cache.set("a", 1) # True / False
80
+ ```
81
+
82
+ ### Background expiration
83
+
84
+ ```python
85
+ # A background thread reclaims expired entries every N seconds, so you don't
86
+ # have to call cleanup_expired() yourself. It stops when the cache is collected.
87
+ cache = Cache(cleanup_interval=30) # seconds (int/float)
67
88
  ```
68
89
 
69
90
  ### Memoization decorator
@@ -86,9 +107,13 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
86
107
 
87
108
  ## API
88
109
 
110
+ Constructor: `Cache(max_size=None, eviction_policy="reject", cleanup_interval=None)`.
111
+ `eviction_policy` must be `"reject"` or `"lru"` (any other value raises
112
+ `ValueError`). `cleanup_interval` (seconds, `> 0`) enables the background sweeper.
113
+
89
114
  | Method | Description |
90
115
  |---|---|
91
- | `set(key, value, ttl=None)` | Store a value. `ttl` in seconds (`int`/`float`); `None` = no expiration; `ttl <= 0` → `ValueError`. Overwrites. |
116
+ | `set(key, value, ttl=None)` | Store a value. `ttl` in seconds (`int`/`float`); `None` = no expiration; `ttl <= 0` → `ValueError`. Overwrites. Returns `True`, or `False` when full under `eviction_policy="reject"` and the key is new. |
92
117
  | `get(key, default=None)` | The value, or `default` if missing/expired (expired entries are removed). |
93
118
  | `delete(key)` | `True` if removed, `False` if it didn't exist. |
94
119
  | `exists(key)` | `True`/`False`, honoring TTL. |
@@ -96,7 +121,8 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
96
121
  | `len()` / `len(cache)` | Approximate size. |
97
122
  | `clear()` | Remove everything (does not reset counters). |
98
123
  | `cleanup_expired()` | Remove expired entries; returns how many. |
99
- | `stats()` | `dict` with `hits, misses, sets, deletes, expired, size`. |
124
+ | `eviction_policy` (property) | The active policy: `"reject"` or `"lru"`. |
125
+ | `stats()` | `dict` with `hits, misses, sets, deletes, expired, evicted, size`. |
100
126
  | `@cache.cached(ttl=None, key=None)` | Memoization decorator. |
101
127
 
102
128
  ## How it works
@@ -105,8 +131,11 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
105
131
  side, via PyO3) and stored as opaque bytes (`Vec<u8>`) in the Rust core.
106
132
  - **Concurrency:** `DashMap` (a HashMap with per-shard locks) plus `AtomicU64`
107
133
  counters, with no global lock on the hot path. Thread-safe, no busy loop.
108
- - **TTL:** expiration is **lazy** — an expired key is removed when accessed
109
- (`get`/`exists`) or via `cleanup_expired()`. There is no background thread in the MVP.
134
+ - **TTL:** expiration is **lazy** by default — an expired key is removed when
135
+ accessed (`get`/`exists`) or via `cleanup_expired()`. Pass `cleanup_interval` to
136
+ also run a **background** sweeper thread that reclaims expired keys on its own.
137
+ - **Eviction:** with `max_size` + `eviction_policy="lru"`, a full cache evicts the
138
+ least-recently-used entry (recency updated on every `get` hit) to admit a new key.
110
139
 
111
140
  ## Limitations
112
141
 
@@ -114,7 +143,8 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
114
143
  - It does **not** replace Redis for distributed caching.
115
144
  - Data is **lost** when the process restarts.
116
145
  - `pickle` must **not** be used to deserialize untrusted data.
117
- - Lazy TTL: expired items may linger until accessed or until `cleanup_expired()`.
146
+ - Lazy TTL by default: without `cleanup_interval`, expired items may linger until
147
+ accessed or until `cleanup_expired()` runs.
118
148
 
119
149
  ## Development
120
150
 
@@ -45,7 +45,28 @@ cache.len() # approximate size
45
45
  cache.keys() # list of keys
46
46
  cache.cleanup_expired() # remove expired entries; returns the count
47
47
  cache.clear() # remove everything (keeps counters)
48
- cache.stats() # {'hits', 'misses', 'sets', 'deletes', 'expired', 'size'}
48
+ cache.stats() # {'hits','misses','sets','deletes','expired','evicted','size'}
49
+ ```
50
+
51
+ ### Bounded cache with LRU eviction
52
+
53
+ ```python
54
+ # Cap the number of keys. When full and a new key arrives, evict the
55
+ # least-recently-used entry instead of rejecting the write.
56
+ cache = Cache(max_size=1000, eviction_policy="lru")
57
+ cache.eviction_policy # "lru"
58
+
59
+ # Default policy is "reject": set() returns False when full (and the key is new).
60
+ cache = Cache(max_size=1000) # eviction_policy="reject"
61
+ cache.set("a", 1) # True / False
62
+ ```
63
+
64
+ ### Background expiration
65
+
66
+ ```python
67
+ # A background thread reclaims expired entries every N seconds, so you don't
68
+ # have to call cleanup_expired() yourself. It stops when the cache is collected.
69
+ cache = Cache(cleanup_interval=30) # seconds (int/float)
49
70
  ```
50
71
 
51
72
  ### Memoization decorator
@@ -68,9 +89,13 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
68
89
 
69
90
  ## API
70
91
 
92
+ Constructor: `Cache(max_size=None, eviction_policy="reject", cleanup_interval=None)`.
93
+ `eviction_policy` must be `"reject"` or `"lru"` (any other value raises
94
+ `ValueError`). `cleanup_interval` (seconds, `> 0`) enables the background sweeper.
95
+
71
96
  | Method | Description |
72
97
  |---|---|
73
- | `set(key, value, ttl=None)` | Store a value. `ttl` in seconds (`int`/`float`); `None` = no expiration; `ttl <= 0` → `ValueError`. Overwrites. |
98
+ | `set(key, value, ttl=None)` | Store a value. `ttl` in seconds (`int`/`float`); `None` = no expiration; `ttl <= 0` → `ValueError`. Overwrites. Returns `True`, or `False` when full under `eviction_policy="reject"` and the key is new. |
74
99
  | `get(key, default=None)` | The value, or `default` if missing/expired (expired entries are removed). |
75
100
  | `delete(key)` | `True` if removed, `False` if it didn't exist. |
76
101
  | `exists(key)` | `True`/`False`, honoring TTL. |
@@ -78,7 +103,8 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
78
103
  | `len()` / `len(cache)` | Approximate size. |
79
104
  | `clear()` | Remove everything (does not reset counters). |
80
105
  | `cleanup_expired()` | Remove expired entries; returns how many. |
81
- | `stats()` | `dict` with `hits, misses, sets, deletes, expired, size`. |
106
+ | `eviction_policy` (property) | The active policy: `"reject"` or `"lru"`. |
107
+ | `stats()` | `dict` with `hits, misses, sets, deletes, expired, evicted, size`. |
82
108
  | `@cache.cached(ttl=None, key=None)` | Memoization decorator. |
83
109
 
84
110
  ## How it works
@@ -87,8 +113,11 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
87
113
  side, via PyO3) and stored as opaque bytes (`Vec<u8>`) in the Rust core.
88
114
  - **Concurrency:** `DashMap` (a HashMap with per-shard locks) plus `AtomicU64`
89
115
  counters, with no global lock on the hot path. Thread-safe, no busy loop.
90
- - **TTL:** expiration is **lazy** — an expired key is removed when accessed
91
- (`get`/`exists`) or via `cleanup_expired()`. There is no background thread in the MVP.
116
+ - **TTL:** expiration is **lazy** by default — an expired key is removed when
117
+ accessed (`get`/`exists`) or via `cleanup_expired()`. Pass `cleanup_interval` to
118
+ also run a **background** sweeper thread that reclaims expired keys on its own.
119
+ - **Eviction:** with `max_size` + `eviction_policy="lru"`, a full cache evicts the
120
+ least-recently-used entry (recency updated on every `get` hit) to admit a new key.
92
121
 
93
122
  ## Limitations
94
123
 
@@ -96,7 +125,8 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
96
125
  - It does **not** replace Redis for distributed caching.
97
126
  - Data is **lost** when the process restarts.
98
127
  - `pickle` must **not** be used to deserialize untrusted data.
99
- - Lazy TTL: expired items may linger until accessed or until `cleanup_expired()`.
128
+ - Lazy TTL by default: without `cleanup_interval`, expired items may linger until
129
+ accessed or until `cleanup_expired()` runs.
100
130
 
101
131
  ## Development
102
132
 
@@ -8,7 +8,7 @@ the stages already shipped and what comes next.
8
8
 
9
9
  ---
10
10
 
11
- ## Shipped — v0.1.x (current: 0.1.2)
11
+ ## Shipped — current: 0.2.0
12
12
 
13
13
  - ✅ `Cache` with `set` / `get(default=...)` / `delete` / `exists` / `keys` /
14
14
  `len` / `clear` / `cleanup_expired` / `stats`.
@@ -24,23 +24,25 @@ the stages already shipped and what comes next.
24
24
 
25
25
  ---
26
26
 
27
- ## Planned
27
+ ## Shipped — v0.2.0
28
+
29
+ ### Background expiration
28
30
 
29
- ### v0.2 — Background expiration
31
+ - ✅ Opt-in background sweeper thread via `Cache(cleanup_interval=...)` (seconds),
32
+ reclaiming expired entries proactively instead of relying only on lazy
33
+ expiration / manual `cleanup_expired()`. The thread is tied to the cache's
34
+ lifetime (stops on `Drop`) and sleeps in short slices for responsive shutdown.
30
35
 
31
- - 🔜 Optional background sweeper thread to reclaim expired entries proactively,
32
- configurable via the constructor (e.g. `Cache(cleanup_interval=...)`), instead
33
- of relying only on lazy expiration / manual `cleanup_expired()`.
34
- - 🔜 Keep the sweeper opt-in and lock-friendly (sweep shard by shard) so it never
35
- stalls the hot path.
36
+ ### LRU eviction
36
37
 
37
- ### v0.3 — LRU eviction
38
+ - ✅ `Cache(max_size=..., eviction_policy="lru")` evicts the **least-recently-used**
39
+ entry once the cap is hit (recency updated on every `get` hit). Default policy
40
+ is `"reject"` (backward compatible). Evictions are surfaced in `stats()` as an
41
+ `evicted` counter.
38
42
 
39
- - 🔜 `Cache(max_size=...)` with **least-recently-used** eviction once the cap is hit.
40
- - 🔜 Track recency cheaply (avoid a global lock); surface evictions in `stats()`
41
- (e.g. an `evicted` counter).
43
+ ## Planned
42
44
 
43
- ### v0.4 — LFU eviction
45
+ ### v0.3 — LFU eviction
44
46
 
45
47
  - 🔜 **Least-frequently-used** eviction policy as an alternative to LRU, selectable
46
48
  at construction time (e.g. `Cache(max_size=..., policy="lfu")`).
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rust-py-cache"
7
- version = "0.1.3"
7
+ version = "0.2.1"
8
8
  description = "An ultra-fast local cache for Python, powered by Rust."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -19,4 +19,4 @@ class Cache(_RustCache):
19
19
 
20
20
 
21
21
  __all__ = ["Cache", "hello"]
22
- __version__ = "0.1.3"
22
+ __version__ = "0.2.1"
@@ -6,6 +6,7 @@ chave e `functools.wraps` — tudo muito mais natural aqui.
6
6
  """
7
7
 
8
8
  import functools
9
+ import inspect
9
10
 
10
11
  # Sentinela para distinguir "ausente no cache" de "valor None legítimo".
11
12
  # `cache.get(key, _MISSING)` devolve `_MISSING` só quando a chave não existe.
@@ -37,25 +38,52 @@ def cached(self, ttl=None, key=None):
37
38
  @cache.cached(ttl=60)
38
39
  def soma(a, b):
39
40
  return a + b
41
+
42
+ @cache.cached(ttl=60)
43
+ async def busca(id):
44
+ return await api.get(id)
45
+
46
+ Funções `async def` são detectadas e recebem um wrapper `async`, que guarda
47
+ no cache o resultado do `await` — não o objeto coroutine. Cachear a coroutine
48
+ faria a segunda chamada levantar `RuntimeError: cannot reuse already awaited
49
+ coroutine`.
40
50
  """
41
51
 
52
+ def _resolve_key(func, args, kwargs):
53
+ if key is None:
54
+ return _default_key(func, args, kwargs)
55
+ if callable(key):
56
+ return key(*args, **kwargs)
57
+ return key
58
+
42
59
  def decorator(func):
43
- @functools.wraps(func)
44
- def wrapper(*args, **kwargs):
45
- if key is None:
46
- cache_key = _default_key(func, args, kwargs)
47
- elif callable(key):
48
- cache_key = key(*args, **kwargs)
49
- else:
50
- cache_key = key
51
-
52
- hit = self.get(cache_key, _MISSING)
53
- if hit is not _MISSING:
54
- return hit
55
-
56
- result = func(*args, **kwargs)
57
- self.set(cache_key, result, ttl=ttl)
58
- return result
60
+ if inspect.iscoroutinefunction(func):
61
+
62
+ @functools.wraps(func)
63
+ async def wrapper(*args, **kwargs):
64
+ cache_key = _resolve_key(func, args, kwargs)
65
+
66
+ hit = self.get(cache_key, _MISSING)
67
+ if hit is not _MISSING:
68
+ return hit
69
+
70
+ result = await func(*args, **kwargs)
71
+ self.set(cache_key, result, ttl=ttl)
72
+ return result
73
+
74
+ else:
75
+
76
+ @functools.wraps(func)
77
+ def wrapper(*args, **kwargs):
78
+ cache_key = _resolve_key(func, args, kwargs)
79
+
80
+ hit = self.get(cache_key, _MISSING)
81
+ if hit is not _MISSING:
82
+ return hit
83
+
84
+ result = func(*args, **kwargs)
85
+ self.set(cache_key, result, ttl=ttl)
86
+ return result
59
87
 
60
88
  # Expõe a chave-base para inspeção/invalidação manual em testes.
61
89
  wrapper.__wrapped__ = func
@@ -9,6 +9,16 @@ use dashmap::DashMap;
9
9
  use crate::entry::CacheEntry;
10
10
  use crate::stats::{CacheStats, StatsSnapshot};
11
11
 
12
+ /// O que fazer quando `max_size` é atingido e chega uma chave **nova**.
13
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14
+ pub enum EvictionPolicy {
15
+ /// Rejeita a escrita (`set` devolve `false`). Comportamento padrão.
16
+ #[default]
17
+ Reject,
18
+ /// Remove a entrada menos recentemente usada (LRU) para abrir espaço.
19
+ Lru,
20
+ }
21
+
12
22
  /// Núcleo concorrente do cache.
13
23
  ///
14
24
  /// `DashMap` é um `HashMap` thread-safe com *sharding*: ele divide o mapa em
@@ -19,18 +29,45 @@ use crate::stats::{CacheStats, StatsSnapshot};
19
29
  /// faz *interior mutability* — ele cuida da sincronização internamente, então
20
30
  /// não precisamos de `&mut self` nem de um `Mutex` por fora. As `stats` seguem
21
31
  /// a mesma ideia com `AtomicU64`.
22
- #[derive(Debug, Default)]
32
+ #[derive(Debug)]
23
33
  pub struct RustCache {
24
34
  store: DashMap<String, CacheEntry>,
25
35
  stats: CacheStats,
36
+ /// Limite opcional de número de chaves.
37
+ max_size: Option<usize>,
38
+ /// Política aplicada quando o cache enche e chega uma chave nova.
39
+ eviction_policy: EvictionPolicy,
26
40
  }
27
41
 
28
42
  impl RustCache {
29
- /// Cria um cache vazio.
30
- pub fn new() -> Self {
43
+ /// Cria um cache vazio. `max_size = None` => sem limite de chaves.
44
+ pub fn new(max_size: Option<usize>, eviction_policy: EvictionPolicy) -> Self {
31
45
  Self {
32
46
  store: DashMap::new(),
33
47
  stats: CacheStats::new(),
48
+ max_size,
49
+ eviction_policy,
50
+ }
51
+ }
52
+
53
+ /// A política de evicção configurada.
54
+ pub fn eviction_policy(&self) -> EvictionPolicy {
55
+ self.eviction_policy
56
+ }
57
+
58
+ /// Remove a entrada com o menor `last_accessed_at` (a "menos recentemente
59
+ /// usada"). Varre o mapa uma vez — O(n) só na evicção (caminho raro: cache
60
+ /// cheio + chave nova). Conta em `evicted`.
61
+ fn evict_lru(&self) {
62
+ let oldest = self
63
+ .store
64
+ .iter()
65
+ .min_by_key(|e| e.value().last_accessed_at)
66
+ .map(|e| e.key().clone());
67
+ if let Some(key) = oldest {
68
+ if self.store.remove(&key).is_some() {
69
+ self.stats.record_evicted();
70
+ }
34
71
  }
35
72
  }
36
73
 
@@ -39,10 +76,25 @@ impl RustCache {
39
76
  /// O core não sabe o que há nos bytes — quem serializa (pickle) é a camada
40
77
  /// `lib.rs`, do lado do Python. Aqui só montamos a `CacheEntry` e inserimos.
41
78
  /// `insert` no `DashMap` pega o lock só do shard daquela chave.
42
- pub fn set(&self, key: String, value: Vec<u8>, expires_at: Option<u64>, now: u64) {
79
+ ///
80
+ /// Sobrescrever uma chave existente sempre é permitido. Para uma chave
81
+ /// **nova** com o cache cheio (`max_size`): com `Reject` devolve `false` sem
82
+ /// inserir; com `Lru` remove a entrada menos recentemente usada e prossegue.
83
+ /// Devolve `true` quando a entrada foi (ou seria) gravada.
84
+ pub fn set(&self, key: String, value: Vec<u8>, expires_at: Option<u64>, now: u64) -> bool {
85
+ if let Some(max) = self.max_size {
86
+ if self.store.len() >= max && !self.store.contains_key(&key) {
87
+ match self.eviction_policy {
88
+ EvictionPolicy::Reject => return false,
89
+ EvictionPolicy::Lru => self.evict_lru(),
90
+ }
91
+ }
92
+ }
93
+
43
94
  self.store
44
95
  .insert(key, CacheEntry::new(value, expires_at, now));
45
96
  self.stats.record_set();
97
+ true
46
98
  }
47
99
 
48
100
  /// Lê os bytes de `key`, aplicando **expiração lazy**.
@@ -57,7 +109,9 @@ impl RustCache {
57
109
  /// vivo, travaríamos o mesmo shard contra nós mesmos (deadlock). Por isso
58
110
  /// `drop(entry)` **antes** do `remove`.
59
111
  pub fn get(&self, key: &str, now: u64) -> Option<Vec<u8>> {
60
- let Some(entry) = self.store.get(key) else {
112
+ // `get_mut` segura o lock de ESCRITA do shard: além de ler o valor,
113
+ // atualizamos `last_accessed_at`/`hits` (base do LRU) no mesmo acesso.
114
+ let Some(mut entry) = self.store.get_mut(key) else {
61
115
  self.stats.record_miss();
62
116
  return None;
63
117
  };
@@ -68,8 +122,11 @@ impl RustCache {
68
122
  self.stats.record_miss();
69
123
  return None;
70
124
  }
125
+ entry.last_accessed_at = now;
126
+ entry.hits += 1;
127
+ let value = entry.value.clone();
71
128
  self.stats.record_hit();
72
- Some(entry.value.clone())
129
+ Some(value)
73
130
  }
74
131
 
75
132
  /// `true` se `key` existe e **não** está expirada. Coleta a chave se expirada.
@@ -166,7 +223,7 @@ mod tests {
166
223
 
167
224
  #[test]
168
225
  fn set_then_get_returns_value() {
169
- let c = RustCache::new();
226
+ let c = RustCache::new(None, EvictionPolicy::Reject);
170
227
  c.set("a".into(), b"v".to_vec(), None, NOW);
171
228
  assert_eq!(c.get("a", NOW), Some(b"v".to_vec()));
172
229
  assert_eq!(c.len(), 1);
@@ -174,14 +231,14 @@ mod tests {
174
231
 
175
232
  #[test]
176
233
  fn get_missing_is_none_and_counts_miss() {
177
- let c = RustCache::new();
234
+ let c = RustCache::new(None, EvictionPolicy::Reject);
178
235
  assert_eq!(c.get("x", NOW), None);
179
236
  assert_eq!(c.stats().misses, 1);
180
237
  }
181
238
 
182
239
  #[test]
183
240
  fn expired_get_collects_and_counts() {
184
- let c = RustCache::new();
241
+ let c = RustCache::new(None, EvictionPolicy::Reject);
185
242
  c.set("a".into(), b"v".to_vec(), Some(NOW + 10), NOW);
186
243
  // ainda válido
187
244
  assert!(c.get("a", NOW + 5).is_some());
@@ -196,7 +253,7 @@ mod tests {
196
253
 
197
254
  #[test]
198
255
  fn exists_respects_ttl() {
199
- let c = RustCache::new();
256
+ let c = RustCache::new(None, EvictionPolicy::Reject);
200
257
  c.set("a".into(), b"v".to_vec(), Some(NOW + 10), NOW);
201
258
  assert!(c.exists("a", NOW + 5));
202
259
  assert!(!c.exists("a", NOW + 20));
@@ -205,7 +262,7 @@ mod tests {
205
262
 
206
263
  #[test]
207
264
  fn delete_counts_only_real_removals() {
208
- let c = RustCache::new();
265
+ let c = RustCache::new(None, EvictionPolicy::Reject);
209
266
  c.set("a".into(), b"v".to_vec(), None, NOW);
210
267
  assert!(c.delete("a"));
211
268
  assert!(!c.delete("a"));
@@ -214,7 +271,7 @@ mod tests {
214
271
 
215
272
  #[test]
216
273
  fn cleanup_removes_only_expired() {
217
- let c = RustCache::new();
274
+ let c = RustCache::new(None, EvictionPolicy::Reject);
218
275
  c.set("keep".into(), b"v".to_vec(), None, NOW);
219
276
  c.set("gone".into(), b"v".to_vec(), Some(NOW + 10), NOW);
220
277
  let removed = c.cleanup_expired(NOW + 20);
@@ -223,4 +280,43 @@ mod tests {
223
280
  assert!(!c.exists("gone", NOW + 20));
224
281
  assert_eq!(c.stats().expired, 1);
225
282
  }
283
+
284
+ #[test]
285
+ fn reject_policy_blocks_new_keys_when_full() {
286
+ let c = RustCache::new(Some(2), EvictionPolicy::Reject);
287
+ assert!(c.set("a".into(), b"1".to_vec(), None, NOW));
288
+ assert!(c.set("b".into(), b"2".to_vec(), None, NOW));
289
+ // cheio + chave nova => rejeita, sem evicção
290
+ assert!(!c.set("c".into(), b"3".to_vec(), None, NOW));
291
+ assert_eq!(c.len(), 2);
292
+ assert_eq!(c.stats().evicted, 0);
293
+ // sobrescrever chave existente é sempre permitido
294
+ assert!(c.set("a".into(), b"10".to_vec(), None, NOW));
295
+ }
296
+
297
+ #[test]
298
+ fn lru_policy_evicts_least_recently_used() {
299
+ let c = RustCache::new(Some(2), EvictionPolicy::Lru);
300
+ c.set("a".into(), b"1".to_vec(), None, NOW);
301
+ c.set("b".into(), b"2".to_vec(), None, NOW);
302
+ // Acessa "a" mais tarde: "b" vira a menos recentemente usada.
303
+ assert!(c.get("a", NOW + 5).is_some());
304
+ // Insere "c": remove "b" (LRU), mantém "a" e "c".
305
+ assert!(c.set("c".into(), b"3".to_vec(), None, NOW + 10));
306
+ assert_eq!(c.len(), 2);
307
+ assert_eq!(c.stats().evicted, 1);
308
+ assert!(c.get("b", NOW + 11).is_none());
309
+ assert!(c.get("a", NOW + 11).is_some());
310
+ assert!(c.get("c", NOW + 11).is_some());
311
+ }
312
+
313
+ #[test]
314
+ fn no_max_size_never_evicts() {
315
+ let c = RustCache::new(None, EvictionPolicy::Lru);
316
+ for i in 0..50 {
317
+ c.set(format!("k{i}"), b"v".to_vec(), None, NOW);
318
+ }
319
+ assert_eq!(c.len(), 50);
320
+ assert_eq!(c.stats().evicted, 0);
321
+ }
226
322
  }
@@ -6,13 +6,18 @@
6
6
  mod cache;
7
7
  mod entry;
8
8
  mod stats;
9
+ mod sweeper;
9
10
  mod ttl;
10
11
 
12
+ use std::sync::Arc;
13
+ use std::time::Duration;
14
+
11
15
  use pyo3::exceptions::PyValueError;
12
16
  use pyo3::prelude::*;
13
17
  use pyo3::types::{PyBytes, PyDict};
14
18
 
15
- use crate::cache::RustCache;
19
+ use crate::cache::{EvictionPolicy, RustCache};
20
+ use crate::sweeper::Sweeper;
16
21
  use crate::ttl::{expires_at_from_ttl, now_ms};
17
22
 
18
23
  /// Serializa um objeto Python em bytes via `pickle.dumps`.
@@ -57,19 +62,61 @@ fn hello() -> PyResult<String> {
57
62
  /// adiciona o decorator `@cache.cached` (ver `python/.../decorators.py`).
58
63
  #[pyclass(subclass)]
59
64
  pub struct Cache {
60
- inner: RustCache,
65
+ inner: Arc<RustCache>,
66
+ /// Thread de expiração em background (quando `cleanup_interval` é dado).
67
+ /// Mantida viva junto com o `Cache`; encerrada no `Drop`. O `_` evita o aviso
68
+ /// de campo não lido — seu efeito é o ciclo de vida, não a leitura.
69
+ _sweeper: Option<Sweeper>,
61
70
  }
62
71
 
63
72
  #[pymethods]
64
73
  impl Cache {
65
- /// `Cache()` — cria um cache vazio.
74
+ /// `Cache(max_size=None, eviction_policy="reject", cleanup_interval=None)`.
66
75
  ///
67
- /// `#[new]` marca o construtor. Devolver `Self` basta: o PyO3 embrulha o
68
- /// valor Rust no objeto Python.
76
+ /// - `max_size`: limite de chaves (`None` = ilimitado).
77
+ /// - `eviction_policy`: `"reject"` (padrão) ou `"lru"` (remove a menos
78
+ /// recentemente usada ao encher). Outro valor levanta `ValueError`.
79
+ /// - `cleanup_interval`: se `> 0`, liga uma thread que varre expirados a cada
80
+ /// N segundos (expiração em background, além da preguiçosa por acesso).
69
81
  #[new]
70
- fn new() -> Self {
71
- Cache {
72
- inner: RustCache::new(),
82
+ #[pyo3(signature = (max_size=None, eviction_policy="reject", cleanup_interval=None))]
83
+ fn new(
84
+ max_size: Option<usize>,
85
+ eviction_policy: &str,
86
+ cleanup_interval: Option<f64>,
87
+ ) -> PyResult<Self> {
88
+ let policy = match eviction_policy {
89
+ "reject" => EvictionPolicy::Reject,
90
+ "lru" => EvictionPolicy::Lru,
91
+ other => {
92
+ return Err(PyValueError::new_err(format!(
93
+ "eviction_policy deve ser \"reject\" ou \"lru\", recebido {other:?}"
94
+ )));
95
+ }
96
+ };
97
+
98
+ let inner = Arc::new(RustCache::new(max_size, policy));
99
+
100
+ let sweeper = match cleanup_interval {
101
+ Some(secs) if secs > 0.0 => Some(Sweeper::start(
102
+ &inner,
103
+ Duration::from_millis((secs * 1000.0) as u64),
104
+ )),
105
+ _ => None,
106
+ };
107
+
108
+ Ok(Cache {
109
+ inner,
110
+ _sweeper: sweeper,
111
+ })
112
+ }
113
+
114
+ /// A política de evicção ativa: `"reject"` ou `"lru"`.
115
+ #[getter]
116
+ fn eviction_policy(&self) -> &'static str {
117
+ match self.inner.eviction_policy() {
118
+ EvictionPolicy::Reject => "reject",
119
+ EvictionPolicy::Lru => "lru",
73
120
  }
74
121
  }
75
122
 
@@ -84,6 +131,9 @@ impl Cache {
84
131
  /// seguramos o GIL — necessário para chamar `pickle`.
85
132
  /// - `value: &Bound<'_, PyAny>` é qualquer objeto Python, emprestado.
86
133
  /// - `#[pyo3(signature = ...)]` define o default `ttl=None` visível no Python.
134
+ /// Devolve `True` quando a entrada foi gravada; `False` apenas quando há
135
+ /// `max_size` com `eviction_policy="reject"`, a chave é nova e o cache está
136
+ /// cheio. Sobrescrever uma chave existente sempre devolve `True`.
87
137
  #[pyo3(signature = (key, value, ttl=None))]
88
138
  fn set(
89
139
  &self,
@@ -91,13 +141,12 @@ impl Cache {
91
141
  key: String,
92
142
  value: &Bound<'_, PyAny>,
93
143
  ttl: Option<f64>,
94
- ) -> PyResult<()> {
144
+ ) -> PyResult<bool> {
95
145
  let now = now_ms();
96
146
  let expires_at = expires_at_from_ttl(ttl, now)
97
147
  .map_err(|_| PyValueError::new_err("ttl deve ser > 0 (ou None para não expirar)"))?;
98
148
  let bytes = dumps(py, value)?;
99
- self.inner.set(key, bytes, expires_at, now);
100
- Ok(())
149
+ Ok(self.inner.set(key, bytes, expires_at, now))
101
150
  }
102
151
 
103
152
  /// `get(key, default=None)` — devolve o valor de `key`.
@@ -147,6 +196,7 @@ impl Cache {
147
196
  d.set_item("sets", s.sets)?;
148
197
  d.set_item("deletes", s.deletes)?;
149
198
  d.set_item("expired", s.expired)?;
199
+ d.set_item("evicted", s.evicted)?;
150
200
  d.set_item("size", self.inner.len())?;
151
201
  Ok(d)
152
202
  }
@@ -18,6 +18,7 @@ pub struct CacheStats {
18
18
  pub sets: AtomicU64,
19
19
  pub deletes: AtomicU64,
20
20
  pub expired: AtomicU64,
21
+ pub evicted: AtomicU64,
21
22
  }
22
23
 
23
24
  /// Cópia "congelada" dos contadores num instante — fácil de mandar pro Python.
@@ -28,6 +29,7 @@ pub struct StatsSnapshot {
28
29
  pub sets: u64,
29
30
  pub deletes: u64,
30
31
  pub expired: u64,
32
+ pub evicted: u64,
31
33
  }
32
34
 
33
35
  impl CacheStats {
@@ -57,6 +59,11 @@ impl CacheStats {
57
59
  self.expired.fetch_add(n, Ordering::Relaxed);
58
60
  }
59
61
 
62
+ /// Conta uma entrada removida por evicção (política LRU ao atingir `max_size`).
63
+ pub fn record_evicted(&self) {
64
+ self.evicted.fetch_add(1, Ordering::Relaxed);
65
+ }
66
+
60
67
  pub fn snapshot(&self) -> StatsSnapshot {
61
68
  StatsSnapshot {
62
69
  hits: self.hits.load(Ordering::Relaxed),
@@ -64,6 +71,7 @@ impl CacheStats {
64
71
  sets: self.sets.load(Ordering::Relaxed),
65
72
  deletes: self.deletes.load(Ordering::Relaxed),
66
73
  expired: self.expired.load(Ordering::Relaxed),
74
+ evicted: self.evicted.load(Ordering::Relaxed),
67
75
  }
68
76
  }
69
77
  }
@@ -0,0 +1,77 @@
1
+ //! Expiração em background: uma thread que varre as entradas expiradas em
2
+ //! intervalos regulares, em vez de depender só da expiração preguiçosa (lazy)
3
+ //! ou de chamadas manuais a `cleanup_expired()`.
4
+ //!
5
+ //! ## Ciclo de vida
6
+ //!
7
+ //! A thread recebe um [`Weak<RustCache>`], não um `Arc` forte: assim ela **não**
8
+ //! mantém o cache vivo. Se o objeto `Cache` do Python for coletado, o `Arc` some,
9
+ //! o `upgrade()` falha e a thread encerra sozinha. Além disso o `Cache` guarda
10
+ //! este [`Sweeper`]; quando ele é destruído (`Drop`), sinalizamos a parada e
11
+ //! fazemos `join` — sem threads órfãs.
12
+ //!
13
+ //! O sono é em fatias curtas (não um único `sleep(interval)`) para o encerramento
14
+ //! responder rápido ao sinal de parada, sem esperar o intervalo inteiro.
15
+
16
+ use std::sync::atomic::{AtomicBool, Ordering};
17
+ use std::sync::{Arc, Weak};
18
+ use std::thread::{self, JoinHandle};
19
+ use std::time::Duration;
20
+
21
+ use crate::cache::RustCache;
22
+ use crate::ttl::now_ms;
23
+
24
+ /// Handle da thread de varredura. Pára e faz `join` automaticamente no `Drop`.
25
+ pub struct Sweeper {
26
+ stop: Arc<AtomicBool>,
27
+ handle: Option<JoinHandle<()>>,
28
+ }
29
+
30
+ impl Sweeper {
31
+ /// Inicia a thread que varre `cache` a cada `interval`.
32
+ pub fn start(cache: &Arc<RustCache>, interval: Duration) -> Self {
33
+ let stop = Arc::new(AtomicBool::new(false));
34
+ let stop_thread = Arc::clone(&stop);
35
+ let weak = Arc::downgrade(cache);
36
+ let handle = thread::spawn(move || run(weak, interval, stop_thread));
37
+ Sweeper {
38
+ stop,
39
+ handle: Some(handle),
40
+ }
41
+ }
42
+ }
43
+
44
+ /// Laço da thread: dorme `interval` (em fatias) e então varre os expirados,
45
+ /// repetindo até receber o sinal de parada ou o cache deixar de existir.
46
+ fn run(cache: Weak<RustCache>, interval: Duration, stop: Arc<AtomicBool>) {
47
+ let tick = Duration::from_millis(200);
48
+ loop {
49
+ let mut waited = Duration::ZERO;
50
+ while waited < interval {
51
+ if stop.load(Ordering::Relaxed) {
52
+ return;
53
+ }
54
+ let chunk = tick.min(interval - waited);
55
+ thread::sleep(chunk);
56
+ waited += chunk;
57
+ }
58
+ if stop.load(Ordering::Relaxed) {
59
+ return;
60
+ }
61
+ match cache.upgrade() {
62
+ Some(c) => {
63
+ c.cleanup_expired(now_ms());
64
+ }
65
+ None => return, // o cache foi coletado; nada mais a varrer
66
+ }
67
+ }
68
+ }
69
+
70
+ impl Drop for Sweeper {
71
+ fn drop(&mut self) {
72
+ self.stop.store(true, Ordering::Relaxed);
73
+ if let Some(handle) = self.handle.take() {
74
+ let _ = handle.join();
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,79 @@
1
+ """Eviction (LRU) + expiração em background.
2
+
3
+ A correção da LRU "qual chave sai" é verificada de forma determinística nos
4
+ testes Rust (`src/cache.rs`), onde o relógio é injetado. Aqui cobrimos a API
5
+ Python: seleção de política, o getter `eviction_policy`, validação, retorno de
6
+ `set` e a thread de expiração em background.
7
+ """
8
+
9
+ import time
10
+
11
+ import pytest
12
+
13
+ from rust_py_cache import Cache
14
+
15
+
16
+ def test_eviction_policy_defaults_to_reject():
17
+ cache = Cache(max_size=2)
18
+ assert cache.eviction_policy == "reject"
19
+
20
+
21
+ def test_eviction_policy_getter_reports_lru():
22
+ cache = Cache(max_size=2, eviction_policy="lru")
23
+ assert cache.eviction_policy == "lru"
24
+
25
+
26
+ def test_invalid_eviction_policy_raises():
27
+ with pytest.raises(ValueError):
28
+ Cache(max_size=2, eviction_policy="lfu")
29
+
30
+
31
+ def test_reject_blocks_new_keys_when_full():
32
+ cache = Cache(max_size=2, eviction_policy="reject")
33
+ assert cache.set("a", 1) is True
34
+ assert cache.set("b", 2) is True
35
+ assert cache.set("c", 3) is False # cheio
36
+ assert len(cache) == 2
37
+ assert cache.stats()["evicted"] == 0
38
+ # sobrescrever chave existente continua permitido
39
+ assert cache.set("a", 10) is True
40
+
41
+
42
+ def test_lru_evicts_to_make_room():
43
+ cache = Cache(max_size=2, eviction_policy="lru")
44
+ cache.set("a", 1)
45
+ cache.set("b", 2)
46
+ cache.set("c", 3) # cheio + chave nova => remove a LRU
47
+ assert len(cache) == 2
48
+ assert cache.stats()["evicted"] == 1
49
+
50
+
51
+ def test_lru_keeps_recently_used():
52
+ cache = Cache(max_size=2, eviction_policy="lru")
53
+ cache.set("a", 1)
54
+ cache.set("b", 2)
55
+ time.sleep(0.01)
56
+ assert cache.get("a") == 1 # "b" passa a ser a menos recentemente usada
57
+ cache.set("c", 3)
58
+ assert cache.get("b") is None # evicted
59
+ assert cache.get("a") == 1
60
+ assert cache.get("c") == 3
61
+
62
+
63
+ def test_no_max_size_never_evicts():
64
+ cache = Cache()
65
+ for i in range(100):
66
+ cache.set(f"k{i}", i)
67
+ assert len(cache) == 100
68
+ assert cache.stats()["evicted"] == 0
69
+
70
+
71
+ def test_background_expiration_sweeps_automatically():
72
+ cache = Cache(cleanup_interval=0.5)
73
+ cache.set("a", 1, ttl=0.5)
74
+ assert len(cache) == 1
75
+ # Sem chamar cleanup_expired(): a thread de fundo deve coletar depois que o
76
+ # TTL (0.5s) vence e o sweeper (0.5s) roda.
77
+ time.sleep(1.6)
78
+ assert len(cache) == 0
79
+ assert cache.stats()["expired"] >= 1
@@ -1,5 +1,6 @@
1
1
  """Testes de exists/keys/stats/cleanup_expired/clear e decorator — Etapas 8-17."""
2
2
 
3
+ import asyncio
3
4
  import time
4
5
 
5
6
  from rust_py_cache import Cache
@@ -56,7 +57,15 @@ def test_stats_tracks_operations():
56
57
  assert s["misses"] == 1
57
58
  assert s["deletes"] == 1
58
59
  assert s["size"] == 0
59
- assert set(s) == {"hits", "misses", "sets", "deletes", "expired", "size"}
60
+ assert set(s) == {
61
+ "hits",
62
+ "misses",
63
+ "sets",
64
+ "deletes",
65
+ "expired",
66
+ "evicted",
67
+ "size",
68
+ }
60
69
 
61
70
 
62
71
  def test_decorator_caches_calls():
@@ -102,3 +111,63 @@ def test_decorator_caches_none_result():
102
111
  assert returns_none() is None
103
112
  assert returns_none() is None
104
113
  assert calls["n"] == 1 # None foi cacheado, não recomputou
114
+
115
+
116
+ def test_decorator_caches_async_function():
117
+ """P0-2: `@cached` sobre `async def` deve cachear o RESULTADO do await.
118
+
119
+ Antes da correção, o wrapper síncrono guardava o objeto coroutine: a
120
+ primeira chamada devolvia o valor certo e a segunda levantava
121
+ `RuntimeError: cannot reuse already awaited coroutine`.
122
+ """
123
+ cache = Cache()
124
+ calls = {"n": 0}
125
+
126
+ @cache.cached(ttl=60)
127
+ async def busca(x):
128
+ calls["n"] += 1
129
+ return x * 2
130
+
131
+ async def cenario():
132
+ # Duas chamadas no mesmo event loop — é aqui que o bug aparecia.
133
+ primeira = await busca(21)
134
+ segunda = await busca(21)
135
+ terceira = await busca(10) # args diferentes -> recomputa
136
+ return primeira, segunda, terceira
137
+
138
+ primeira, segunda, terceira = asyncio.run(cenario())
139
+
140
+ assert primeira == 42
141
+ assert segunda == 42
142
+ assert terceira == 20
143
+ assert calls["n"] == 2 # busca(21) rodou uma vez só
144
+
145
+
146
+ def test_decorator_async_keeps_coroutine_function():
147
+ """O wrapper de uma `async def` continua sendo uma coroutine function."""
148
+ cache = Cache()
149
+
150
+ @cache.cached()
151
+ async def f():
152
+ return 1
153
+
154
+ assert asyncio.iscoroutinefunction(f)
155
+ assert f.__name__ == "f"
156
+
157
+
158
+ def test_decorator_async_caches_none_result():
159
+ cache = Cache()
160
+ calls = {"n": 0}
161
+
162
+ @cache.cached()
163
+ async def returns_none():
164
+ calls["n"] += 1
165
+ return None
166
+
167
+ async def cenario():
168
+ return await returns_none(), await returns_none()
169
+
170
+ a, b = asyncio.run(cenario())
171
+ assert a is None
172
+ assert b is None
173
+ assert calls["n"] == 1
File without changes
File without changes